Compare commits

..
4 Commits
Author SHA1 Message Date
Denozordec 632adaa63f refactor(lookup): simplify layout by removing Frame components
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 46s
CI / web (push) Successful in 1m23s
CI / release (push) Successful in 4m16s
Refactored the LookupAddStep, LookupMatchesGrid, LookupSearchForm, and LookupWizard components to eliminate Frame components, replacing them with simpler div structures for improved readability and maintainability. Updated comments to reflect the new layout and functionality, aligning with the wizard-2 design pattern.
2026-07-31 15:21:16 +07:00
Denozordec cc80858952 refactor(lookup): remove LookupSummaryKpi component and update LookupWizard
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 47s
CI / web (push) Successful in 1m8s
CI / release (push) Successful in 4m19s
Deleted the LookupSummaryKpi component to streamline the lookup functionality. Updated the LookupWizard component to remove references to the deleted component and adjusted the layout to incorporate FrameFooter for improved user interaction. Modified API type documentation to reflect changes in the lookup membership wizard.
2026-07-31 13:42:02 +07:00
Denozordec b8dbf15254 feat(api): update types to support CIDR in lookup queries
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 54s
CI / release (push) Successful in 3m56s
Enhanced the API type definitions to include CIDR support alongside IP and domain in the lookup functionality. Updated relevant comments and descriptions to reflect the new capabilities, ensuring clarity in the API documentation.
2026-07-31 13:24:52 +07:00
DenozordecandCursor f45b2bed90 feat(lookup): enhance lookup functionality to support CIDR queries
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Failing after 40s
CI / web (push) Successful in 56s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 14s
CI / release (push) Skipped
Updated the lookup system to allow for CIDR queries in addition to IP and domain searches. This includes modifications to the API, frontend components, and documentation to reflect the new capabilities. The LookupSearchForm and LookupComponent were adjusted to accommodate CIDR input, and relevant tests were added to ensure functionality.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 13:20:18 +07:00
15 changed files with 1252 additions and 181 deletions
@@ -0,0 +1,190 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Field, FieldLabel } from '@evobgp/ui/components/field'
import { CommunitySelect } from '@/components/modules/community-select'
import { LoadingButton } from '@/components/loading-button'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { SelectMenu } from '@/components/select-field'
import { ApiError, apiMutate } from '@/lib/api-client'
import type {
BgpCommunity,
LookupQueryKind,
LookupResponse,
ModuleRow,
} from '@/types/api'
/**
* Lookup wizard step 3 — module + community (bare content for single Frame).
* @see https://reui.io/preview/base/wizard-2
*/
function hostPrefixFromIp(ip: string): string {
return ip.includes(':') ? `${ip}/128` : `${ip}/32`
}
function moduleTypeForKind(kind: LookupQueryKind): ModuleRow['type'] {
return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES'
}
export function LookupAddStep({
data,
modules,
communities,
onCancel,
onAdded,
}: {
data: LookupResponse
modules: ModuleRow[]
communities: BgpCommunity[]
onCancel: () => void
onAdded: () => void | Promise<void>
}) {
const wantedType = moduleTypeForKind(data.query_kind)
const eligible = useMemo(
() => modules.filter((m) => m.type === wantedType),
[modules, wantedType],
)
const [moduleId, setModuleId] = useState('')
const [communityId, setCommunityId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (eligible.length === 0) {
setModuleId('')
return
}
setModuleId((prev) =>
prev && eligible.some((m) => m.id === prev) ? prev : eligible[0]!.id,
)
}, [eligible])
useEffect(() => {
const mod = eligible.find((m) => m.id === moduleId)
if (!mod) {
setCommunityId(null)
return
}
setCommunityId(mod.default_community_id ?? null)
}, [moduleId, eligible])
const moduleItems = useMemo(
() =>
eligible.map((m) => ({
value: m.id,
label: m.name,
})),
[eligible],
)
async function handleAdd() {
if (!moduleId) {
toast.error('Выберите модуль')
return
}
if (data.query_kind !== 'domain' && !communityId) {
toast.error('Укажите community')
return
}
setSaving(true)
try {
if (data.query_kind === 'domain') {
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', {
fqdn: data.normalized,
community_id: communityId,
})
toast.success('Домен добавлен')
} else {
const prefix =
data.query_kind === 'cidr'
? data.normalized
: hostPrefixFromIp(data.normalized)
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', {
prefix,
community_id: communityId,
})
toast.success('Префикс добавлен')
}
await onAdded()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
if (eligible.length === 0) {
return (
<Alert variant="warning">
<AlertTitle>Нет подходящего модуля</AlertTitle>
<AlertDescription>
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
Перейти к модулям
</Button>
</AlertDescription>
</Alert>
)
}
const valueLabel =
data.query_kind === 'domain'
? data.normalized
: data.query_kind === 'cidr'
? data.normalized
: hostPrefixFromIp(data.normalized)
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-semibold">Добавить в списки</h3>
<p className="text-muted-foreground text-sm">
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
</p>
</div>
<div className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="lookup-add-module">Модуль ({wantedType})</FieldLabel>
<SelectMenu
id="lookup-add-module"
items={moduleItems}
value={moduleId}
placeholder="Выберите модуль"
onValueChange={(v) => {
if (v) setModuleId(v)
}}
/>
</Field>
<CommunitySelect
id="lookup-add-comm"
label="Community"
value={communityId}
onValueChange={setCommunityId}
communities={communities}
nullable={data.query_kind === 'domain'}
/>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Назад
</Button>
<LoadingButton
loading={saving}
onClick={() => void handleAdd()}
disabled={!moduleId || (data.query_kind !== 'domain' && !communityId)}
>
Добавить
</LoadingButton>
</div>
</div>
)
}
@@ -4,7 +4,6 @@ import { useNavigate } from '@tanstack/react-router'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { FrameDataGrid } from '@/components/reui-kit'
import { DataGridSection } from '@/components/data-grid-shell'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
@@ -12,7 +11,8 @@ import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { LookupMatch } from '@/types/api'
/**
* Lookup matches grid — data-grid-filtering-2 pattern.
* Lookup matches grid — bare section for wizard-2 single Frame.
* @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/docs/components/base/badge
*/
@@ -113,10 +113,13 @@ export function LookupMatchesGrid({
})
return (
<FrameDataGrid
title="Совпадения"
description="Entries и snapshots · клик по строке открывает модуль"
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-px">
<h3 className="text-sm font-semibold">Совпадения</h3>
<p className="text-muted-foreground text-sm">
Entries и snapshots · клик по строке открывает модуль
</p>
</div>
<DataGridSection
table={table}
recordCount={filteredCount}
@@ -129,6 +132,6 @@ export function LookupMatchesGrid({
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
}
/>
</FrameDataGrid>
</div>
)
}
@@ -8,17 +8,10 @@ import {
InputGroupAddon,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
/**
* Lookup search form — Frame + InputGroup (form-7 pattern).
* @see https://reui.io/preview/base/form-7
* Lookup search fields — bare form for wizard-2 single-Frame surface.
* @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/docs/components/base/frame
*/
export function LookupSearchForm({
@@ -40,37 +33,27 @@ export function LookupSearchForm({
}
return (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Проверка списка</FrameTitle>
<FrameDescription>
IP или FQDN поиск в entries и материализованных snapshots с community.
</FrameDescription>
</FrameHeader>
<FramePanel>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor="lookup-q">IP или домен</FieldLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<Search aria-hidden />
</InputGroupAddon>
<InputGroupInput
id="lookup-q"
name="q"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="8.8.8.8 или example.com"
autoComplete="off"
autoFocus
/>
</InputGroup>
</Field>
<Button type="submit" disabled={isPending || !value.trim()}>
Проверить
</Button>
</form>
</FramePanel>
</Frame>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor="lookup-q">IP, CIDR или домен</FieldLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<Search aria-hidden />
</InputGroupAddon>
<InputGroupInput
id="lookup-q"
name="q"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="8.8.8.8, 203.0.113.0/24 или example.com"
autoComplete="off"
autoFocus
/>
</InputGroup>
</Field>
<Button type="submit" disabled={isPending || !value.trim()}>
Проверить
</Button>
</form>
)
}
@@ -1,62 +0,0 @@
import { Globe, Layers, ListChecks, Radar } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import type { LookupResponse } from '@/types/api'
/**
* Lookup summary KPI — stats-12 via KpiStatGrid.
* @see https://reui.io/preview/base/stats-12
*/
export function LookupSummaryKpi({ data }: { data: LookupResponse }) {
const entryCount = data.matches.filter((m) => m.layer === 'entry').length
const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length
const resolvedCount = data.resolved_ips?.length ?? 0
const items: KpiStatItem[] = [
{
id: 'matched',
label: 'Результат',
value: data.matched ? 'Найдено' : 'Не найдено',
hint: data.normalized,
icon: <Radar aria-hidden />,
iconClassName: data.matched
? 'bg-success text-success-foreground [&_svg]:text-success-foreground'
: 'bg-muted text-muted-foreground [&_svg]:text-muted-foreground',
variant: data.matched ? 'default' : 'warning',
},
{
id: 'entry',
label: 'Слой entry',
value: entryCount,
hint: 'сырые списки',
icon: <ListChecks aria-hidden />,
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
},
{
id: 'snapshot',
label: 'Слой snapshot',
value: snapshotCount,
hint: 'материализация',
icon: <Layers aria-hidden />,
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
},
]
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 (
<KpiStatGrid
items={items}
aria-label={`Запрос: ${data.query_kind} · ${data.query}`}
/>
)
}
@@ -0,0 +1,363 @@
import { useEffect, useState, type ReactNode } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, Plus, Search, ShieldAlert } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { LookupAddStep } from '@/components/lookup/lookup-add-step'
import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid'
import { LookupSearchForm } from '@/components/lookup/lookup-search-form'
import { EmptyState } from '@/components/empty-state'
import { QueryState } from '@/components/query-state'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import {
Stepper,
StepperContent,
StepperDescription,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@/components/reui/stepper'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteModules } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
import { lookupKeys, lookupQueryOptions } from '@/queries/lookup'
import { modulesListQueryOptions } from '@/queries/modules'
/**
* Lookup membership wizard — one Frame (wizard-2), all content inside.
* @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/docs/components/base/stepper
* @see https://reui.io/docs/components/base/frame
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
const STEP_QUERY = 1
const STEP_RESULT = 2
const STEP_ADD = 3
const STEP_DONE = 4
function WizardActions({ children }: { children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
{children}
</div>
)
}
export function LookupWizard({
q,
onSubmitQuery,
}: {
q: string
onSubmitQuery: (next: string) => void
}) {
const queryClient = useQueryClient()
const trimmed = q.trim()
const lookupQ = useQuery(lookupQueryOptions(trimmed))
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteModules(sessionQ.data)
const [step, setStep] = useState(trimmed ? STEP_RESULT : STEP_QUERY)
const [offerAdd, setOfferAdd] = useState(false)
const loadCatalog = step === STEP_ADD || step === STEP_DONE
const modulesQ = useQuery({
...modulesListQueryOptions(),
enabled: loadCatalog,
})
const communitiesQ = useQuery({
...directoriesCommunitiesQueryOptions(),
enabled: loadCatalog,
})
useEffect(() => {
setOfferAdd(false)
setStep(trimmed ? STEP_RESULT : STEP_QUERY)
}, [trimmed])
function resetToQuery() {
setOfferAdd(false)
setStep(STEP_QUERY)
onSubmitQuery('')
}
function goToAdd() {
setOfferAdd(true)
setStep(STEP_ADD)
}
async function handleAdded() {
setStep(STEP_DONE)
await queryClient.invalidateQueries({ queryKey: lookupKeys.query(trimmed) })
await lookupQ.refetch()
}
const data = lookupQ.data
const notFound = data != null && !data.matched
return (
<div className="flex flex-col gap-4 md:gap-6">
<Frame spacing="sm" className="w-full">
<FramePanel className="flex flex-col gap-6">
<FrameHeader className="px-0 py-0">
<FrameTitle>Мастер проверки</FrameTitle>
<FrameDescription>
Проверка IP / CIDR / домена в списках, затем при необходимости добавление.
</FrameDescription>
</FrameHeader>
<Stepper
value={step}
onValueChange={setStep}
className="flex flex-col gap-6"
indicators={{
completed: <Check className="size-4" />,
}}
>
<StepperNav>
<StepperItem step={STEP_QUERY} completed={step > STEP_QUERY}>
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<StepperIndicator>{STEP_QUERY}</StepperIndicator>
<div className="flex flex-col items-start">
<StepperTitle>Запрос</StepperTitle>
<StepperDescription className="hidden sm:block">
IP, CIDR или FQDN
</StepperDescription>
</div>
</StepperTrigger>
<StepperSeparator className="max-sm:hidden" />
</StepperItem>
<StepperItem
step={STEP_RESULT}
completed={step > STEP_RESULT}
disabled={!trimmed}
>
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<StepperIndicator>{STEP_RESULT}</StepperIndicator>
<div className="flex flex-col items-start">
<StepperTitle>Результат</StepperTitle>
<StepperDescription className="hidden sm:block">
Есть в списках?
</StepperDescription>
</div>
</StepperTrigger>
<StepperSeparator className="max-sm:hidden" />
</StepperItem>
<StepperItem
step={STEP_ADD}
completed={step > STEP_ADD}
disabled={!offerAdd && step !== STEP_ADD && step !== STEP_DONE}
>
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<StepperIndicator>{STEP_ADD}</StepperIndicator>
<div className="flex flex-col items-start">
<StepperTitle>Добавление</StepperTitle>
<StepperDescription className="hidden sm:block">
Модуль и community
</StepperDescription>
</div>
</StepperTrigger>
<StepperSeparator className="max-sm:hidden" />
</StepperItem>
<StepperItem step={STEP_DONE} completed={step === STEP_DONE}>
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<StepperIndicator>{STEP_DONE}</StepperIndicator>
<div className="flex flex-col items-start">
<StepperTitle>Готово</StepperTitle>
<StepperDescription className="hidden sm:block">
Подтверждение
</StepperDescription>
</div>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={STEP_QUERY}>
<div className="flex flex-col gap-6">
<LookupSearchForm
key={`form-${trimmed}`}
initialQuery={trimmed}
isPending={lookupQ.isFetching}
onSubmit={onSubmitQuery}
/>
{!trimmed ? (
<EmptyState
icon={<Search className="size-8" />}
title="Введите IP, CIDR или домен"
description="Например 8.8.8.8, 203.0.113.0/24 или example.com — проверка по entries и snapshots."
/>
) : null}
</div>
</StepperContent>
<StepperContent value={STEP_RESULT}>
{!trimmed ? (
<EmptyState
icon={<Search className="size-8" />}
title="Сначала выполните проверку"
description="Вернитесь к шагу «Запрос» и укажите значение."
/>
) : (
<QueryState
data={lookupQ.data}
isLoading={lookupQ.isLoading}
isError={lookupQ.isError}
error={lookupQ.error}
onRetry={() => void lookupQ.refetch()}
skeleton={<TableSkeleton rows={5} />}
>
{(result) => (
<div className="flex flex-col gap-4 md:gap-6">
<LookupSearchForm
key={`result-form-${trimmed}`}
initialQuery={trimmed}
isPending={lookupQ.isFetching}
onSubmit={onSubmitQuery}
/>
{result.matched ? (
<>
<Alert variant="success">
<Check className="size-4" />
<AlertTitle>Уже есть в списках</AlertTitle>
<AlertDescription>
«{result.normalized}» найден в entries и/или snapshots
({result.match_count} совпад.).
</AlertDescription>
</Alert>
<LookupMatchesGrid
items={result.matches}
isLoading={lookupQ.isFetching}
/>
<WizardActions>
<Button
type="button"
variant="outline"
onClick={resetToQuery}
>
Новая проверка
</Button>
</WizardActions>
</>
) : canWrite ? (
<>
<Alert variant="info">
<Plus className="size-4" />
<AlertTitle>Не найдено в списках</AlertTitle>
<AlertDescription>
«{result.normalized}» отсутствует. Добавить запись?
</AlertDescription>
</Alert>
<WizardActions>
<Button
type="button"
variant="outline"
onClick={resetToQuery}
>
Новая проверка
</Button>
<Button type="button" onClick={() => goToAdd()}>
Добавить
</Button>
</WizardActions>
</>
) : (
<>
<Alert variant="warning">
<ShieldAlert className="size-4" />
<AlertTitle>Не найдено</AlertTitle>
<AlertDescription>
«{result.normalized}» отсутствует в списках. У вас нет
права добавлять записи (нужно bgp:modules:write).
</AlertDescription>
</Alert>
<WizardActions>
<Button
type="button"
variant="outline"
onClick={resetToQuery}
>
Новая проверка
</Button>
</WizardActions>
</>
)}
</div>
)}
</QueryState>
)}
</StepperContent>
<StepperContent value={STEP_ADD}>
{data && notFound ? (
<LookupAddStep
data={data}
modules={modulesQ.data?.items ?? []}
communities={communitiesQ.data?.items ?? []}
onCancel={() => {
setOfferAdd(false)
setStep(STEP_RESULT)
}}
onAdded={handleAdded}
/>
) : (
<Alert variant="warning">
<AlertTitle>Добавление недоступно</AlertTitle>
<AlertDescription>
Сначала выполните проверку для значения, которого ещё нет в списках.
</AlertDescription>
</Alert>
)}
</StepperContent>
<StepperContent value={STEP_DONE}>
<div className="flex flex-col gap-4 md:gap-6">
<Alert variant="success">
<Check className="size-4" />
<AlertTitle>Запись добавлена</AlertTitle>
<AlertDescription>
Повторная проверка обновлена. Можно посмотреть совпадения или
начать новый запрос.
</AlertDescription>
</Alert>
{lookupQ.data?.matched ? (
<LookupMatchesGrid
items={lookupQ.data.matches}
isLoading={lookupQ.isFetching}
/>
) : null}
<WizardActions>
<Button type="button" variant="outline" onClick={resetToQuery}>
Новая проверка
</Button>
<Button type="button" onClick={() => setStep(STEP_RESULT)}>
К результату
</Button>
</WizardActions>
</div>
</StepperContent>
</StepperPanel>
</Stepper>
</FramePanel>
</Frame>
</div>
)
}
+470
View File
@@ -0,0 +1,470 @@
import type { HTMLAttributes, ReactElement } from "react"
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@evobgp/ui/lib/utils"
// Types
type StepperOrientation = "horizontal" | "vertical"
type StepState = "active" | "completed" | "inactive" | "loading"
type StepIndicators = {
active?: React.ReactNode
completed?: React.ReactNode
inactive?: React.ReactNode
loading?: React.ReactNode
}
interface StepperContextValue {
activeStep: number
setActiveStep: (step: number) => void
stepsCount: number
orientation: StepperOrientation
registerTrigger: (node: HTMLButtonElement | null) => void
triggerNodes: HTMLButtonElement[]
focusNext: (currentIdx: number) => void
focusPrev: (currentIdx: number) => void
focusFirst: () => void
focusLast: () => void
indicators: StepIndicators
}
interface StepItemContextValue {
step: number
state: StepState
isDisabled: boolean
isLoading: boolean
}
const StepperContext = createContext<StepperContextValue | undefined>(undefined)
const StepItemContext = createContext<StepItemContextValue | undefined>(
undefined
)
function useStepper() {
const ctx = useContext(StepperContext)
if (!ctx) throw new Error("useStepper must be used within a Stepper")
return ctx
}
function useStepItem() {
const ctx = useContext(StepItemContext)
if (!ctx) throw new Error("useStepItem must be used within a StepperItem")
return ctx
}
interface StepperProps extends HTMLAttributes<HTMLDivElement> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: StepperOrientation
indicators?: StepIndicators
}
function Stepper({
defaultValue = 1,
value,
onValueChange,
orientation = "horizontal",
className,
children,
indicators = {},
...props
}: StepperProps) {
const [activeStep, setActiveStep] = useState(defaultValue)
const [triggerNodes, setTriggerNodes] = useState<HTMLButtonElement[]>([])
// Register/unregister triggers
const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
setTriggerNodes((prev) => {
if (node && !prev.includes(node)) {
return [...prev, node]
} else if (!node && prev.includes(node!)) {
return prev.filter((n) => n !== node)
} else {
return prev
}
})
}, [])
const handleSetActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setActiveStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
// Keyboard navigation logic
const focusTrigger = (idx: number) => {
if (triggerNodes[idx]) triggerNodes[idx].focus()
}
const focusNext = (currentIdx: number) =>
focusTrigger((currentIdx + 1) % triggerNodes.length)
const focusPrev = (currentIdx: number) =>
focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length)
const focusFirst = () => focusTrigger(0)
const focusLast = () => focusTrigger(triggerNodes.length - 1)
// Context value
const contextValue = useMemo<StepperContextValue>(
() => ({
activeStep: currentStep,
setActiveStep: handleSetActiveStep,
stepsCount: Children.toArray(children).filter(
(child): child is ReactElement =>
isValidElement(child) &&
(child.type as { displayName?: string }).displayName === "StepperItem"
).length,
orientation,
registerTrigger,
focusNext,
focusPrev,
focusFirst,
focusLast,
triggerNodes,
indicators,
}),
[
currentStep,
handleSetActiveStep,
children,
orientation,
registerTrigger,
triggerNodes,
]
)
return (
<StepperContext.Provider value={contextValue}>
<div
role="tablist"
aria-orientation={orientation}
data-slot="stepper"
className={cn("w-full", className)}
data-orientation={orientation}
{...props}
>
{children}
</div>
</StepperContext.Provider>
)
}
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number
completed?: boolean
disabled?: boolean
loading?: boolean
}
function StepperItem({
step,
completed = false,
disabled = false,
loading = false,
className,
children,
...props
}: StepperItemProps) {
const { activeStep } = useStepper()
const state: StepState =
completed || step < activeStep
? "completed"
: activeStep === step
? "active"
: "inactive"
const isLoading = loading && step === activeStep
return (
<StepItemContext.Provider
value={{ step, state, isDisabled: disabled, isLoading }}
>
<div
data-slot="stepper-item"
className={cn(
"group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col",
className
)}
data-state={state}
{...(isLoading ? { "data-loading": true } : {})}
{...props}
>
{children}
</div>
</StepItemContext.Provider>
)
}
type StepperTriggerProps = useRender.ComponentProps<"button">
function StepperTrigger({
className,
children,
tabIndex,
render,
...props
}: StepperTriggerProps) {
const { state, isLoading } = useStepItem()
const stepperCtx = useStepper()
const {
setActiveStep,
activeStep,
registerTrigger,
triggerNodes,
focusNext,
focusPrev,
focusFirst,
focusLast,
} = stepperCtx
const { step, isDisabled } = useStepItem()
const isSelected = activeStep === step
const id = `stepper-tab-${step}`
const panelId = `stepper-panel-${step}`
// Register this trigger for keyboard navigation
const btnRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (btnRef.current) {
registerTrigger(btnRef.current)
}
}, [btnRef.current])
// Find our index among triggers for navigation
const myIdx = useMemo(
() =>
triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
[triggerNodes, btnRef.current]
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault()
if (myIdx !== -1 && focusNext) focusNext(myIdx)
break
case "ArrowLeft":
case "ArrowUp":
e.preventDefault()
if (myIdx !== -1 && focusPrev) focusPrev(myIdx)
break
case "Home":
e.preventDefault()
if (focusFirst) focusFirst()
break
case "End":
e.preventDefault()
if (focusLast) focusLast()
break
case "Enter":
case " ":
e.preventDefault()
setActiveStep(step)
break
}
}
const defaultProps = {
role: "tab",
id,
"aria-selected": isSelected,
"aria-controls": panelId,
tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1,
"data-slot": "stepper-trigger",
"data-state": state,
"data-loading": isLoading,
className: cn(
"focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60",
"gap-2.5 rounded-full",
className
),
onClick: () => setActiveStep(step),
onKeyDown: handleKeyDown,
disabled: isDisabled,
children,
}
return useRender({
defaultTagName: "button",
render,
ref: btnRef,
props: mergeProps<"button">(defaultProps, props),
})
}
function StepperIndicator({
children,
className,
}: React.ComponentProps<"div">) {
const { state, isLoading } = useStepItem()
const { indicators } = useStepper()
return (
<div
data-slot="stepper-indicator"
data-state={state}
className={cn(
"border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden",
"rounded-full text-xs",
className
)}
>
<div className="absolute">
{indicators &&
((isLoading && indicators.loading) ||
(state === "completed" && indicators.completed) ||
(state === "active" && indicators.active) ||
(state === "inactive" && indicators.inactive))
? (isLoading && indicators.loading) ||
(state === "completed" && indicators.completed) ||
(state === "active" && indicators.active) ||
(state === "inactive" && indicators.inactive)
: children}
</div>
</div>
)
}
function StepperSeparator({ className }: React.ComponentProps<"div">) {
const { state } = useStepItem()
return (
<div
data-slot="stepper-separator"
data-state={state}
className={cn(
"bg-muted rounded-sm m-0.5 group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5",
className
)}
/>
)
}
function StepperTitle({ children, className }: React.ComponentProps<"h3">) {
const { state } = useStepItem()
return (
<h3
data-slot="stepper-title"
data-state={state}
className={cn("text-sm leading-none font-medium", className)}
>
{children}
</h3>
)
}
function StepperDescription({
children,
className,
}: React.ComponentProps<"div">) {
const { state } = useStepItem()
return (
<div
data-slot="stepper-description"
data-state={state}
className={cn("text-muted-foreground text-sm", className)}
>
{children}
</div>
)
}
function StepperNav({ children, className }: React.ComponentProps<"nav">) {
const { activeStep, orientation } = useStepper()
return (
<nav
data-slot="stepper-nav"
data-state={activeStep}
data-orientation={orientation}
className={cn(
"group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
)}
>
{children}
</nav>
)
}
function StepperPanel({ children, className }: React.ComponentProps<"div">) {
const { activeStep } = useStepper()
return (
<div
data-slot="stepper-panel"
data-state={activeStep}
className={cn("w-full", className)}
>
{children}
</div>
)
}
interface StepperContentProps extends React.ComponentProps<"div"> {
value: number
forceMount?: boolean
}
function StepperContent({
value,
forceMount,
children,
className,
}: StepperContentProps) {
const { activeStep } = useStepper()
const isActive = value === activeStep
if (!forceMount && !isActive) {
return null
}
return (
<div
data-slot="stepper-content"
data-state={activeStep}
className={cn("w-full", className, !isActive && forceMount && "hidden")}
hidden={!isActive && forceMount}
>
{children}
</div>
)
}
export {
useStepper,
useStepItem,
Stepper,
StepperItem,
StepperTrigger,
StepperIndicator,
StepperSeparator,
StepperTitle,
StepperDescription,
StepperPanel,
StepperContent,
StepperNav,
type StepperProps,
type StepperItemProps,
type StepperTriggerProps,
type StepperContentProps,
}
+25
View File
@@ -283,6 +283,31 @@ export function sessionCanManageApiKeys(session: {
return session.role === 'operator'
}
/**
* Whether session may create/update module entries (`bgp:modules:write`).
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+.
*/
export function sessionCanWriteModules(session: {
role?: string
kind?: string
is_admin?: boolean
permissions?: readonly string[]
} | null | undefined): boolean {
if (!session) return false
const jwtPath =
session.kind === 'jwt' ||
session.is_admin === true ||
(session.permissions?.length ?? 0) > 0
if (jwtPath) {
return (
session.is_admin === true ||
hasPermission(session.permissions ?? [], 'bgp:modules:write')
)
}
const role = (session.role ?? '').toLowerCase()
return role === 'editor' || role === 'operator'
}
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
export function permissionForPath(pathname: string): string | null {
if (pathname === '/' || pathname.startsWith('/dashboard')) {
+10 -57
View File
@@ -1,23 +1,16 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Search } from 'lucide-react'
import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid'
import { LookupSearchForm } from '@/components/lookup/lookup-search-form'
import { LookupSummaryKpi } from '@/components/lookup/lookup-summary-kpi'
import { LookupWizard } from '@/components/lookup/lookup-wizard'
import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { lookupQueryOptions } from '@/queries/lookup'
/**
* Quick membership lookup page.
* Surface: frame · KPI: stats-12 · form: form-7 · grid: data-grid-filtering-2 · empty: empty-state-2
* Quick membership lookup wizard.
* Surface: frame · wizard-2 · stepper · KPI: stats-12 · grid: data-grid-filtering-2
* @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/docs/components/base/stepper
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/preview/base/form-7
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/preview/base/empty-state-2
* @see https://reui.io/preview/base/empty-state-12
*/
export const Route = createFileRoute('/_auth/lookup')({
component: LookupComponent,
@@ -29,58 +22,18 @@ export const Route = createFileRoute('/_auth/lookup')({
function LookupComponent() {
const { q } = useSearch({ from: '/_auth/lookup' })
const navigate = Route.useNavigate()
const lookupQ = useQuery(lookupQueryOptions(q))
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Проверка"
description="Быстрая проверка IP или домена в списках и community (entry + snapshot)"
description="Мастер: проверка IP / CIDR / домена в списках и community, при отсутствии — добавление"
/>
<LookupSearchForm
key={q}
initialQuery={q}
isPending={lookupQ.isFetching}
onSubmit={(next) => void navigate({ search: { q: next } })}
<LookupWizard
q={q}
onSubmitQuery={(next) => void navigate({ search: { q: next } })}
/>
{!q.trim() ? (
<EmptyState
icon={<Search className="size-8" />}
title="Введите IP или домен"
description="Например 8.8.8.8 или example.com — проверка по сырым entries и материализованным префиксам."
/>
) : (
<QueryState
data={lookupQ.data}
isLoading={lookupQ.isLoading}
isError={lookupQ.isError}
error={lookupQ.error}
onRetry={() => void lookupQ.refetch()}
skeleton={
<div className="flex flex-col gap-4 md:gap-6">
<SectionCardsSkeleton />
<TableSkeleton rows={5} />
</div>
}
>
{(data) => (
<div className="flex flex-col gap-4 md:gap-6">
<LookupSummaryKpi data={data} />
{data.matched ? (
<LookupMatchesGrid items={data.matches} isLoading={lookupQ.isFetching} />
) : (
<EmptyState
icon={<Search className="size-8" />}
title="Не найдено в списках"
description={`«${data.normalized}» отсутствует в entries и snapshots tenant.`}
/>
)}
</div>
)}
</QueryState>
)}
</div>
)
}
+6 -4
View File
@@ -114,11 +114,13 @@ export interface paths {
cookie?: never;
};
/**
* Проверка IP или домена в списках
* Проверка IP, CIDR или домена в списках
* @description Быстрая membership-проверка по tenant:
*
* - **IP** слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot`
* (все module prefix snapshots, `Prefix.Contains`);
* - **CIDR** слой `entry` (`IP_RANGES`, равенство masked-префикса или покрытие
* запрошенной сети) и слой `snapshot` (то же правило);
* - **Domain** слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot`
* (префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть);
* затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка
@@ -2167,7 +2169,7 @@ export interface components {
* @description Определённый тип запроса после нормализации.
* @enum {string}
*/
LookupQueryKind: "ip" | "domain";
LookupQueryKind: "ip" | "domain" | "cidr";
/**
* @description `entry` сырые IP_RANGES / DOMAINS entries;
* `snapshot` материализованные префиксы `module_prefix_snapshot`.
@@ -2206,7 +2208,7 @@ export interface components {
/** @description Исходная строка запроса. */
query: string;
query_kind: components["schemas"]["LookupQueryKind"];
/** @description Нормализованный IP или FQDN. */
/** @description Нормализованный IP, masked CIDR или FQDN. */
normalized: string;
/** @description true, если есть хотя бы одно совпадение. */
matched: boolean;
@@ -3033,7 +3035,7 @@ export interface operations {
lookupMembership: {
parameters: {
query: {
/** @description IP-адрес или FQDN для проверки. */
/** @description IP-адрес, CIDR или FQDN для проверки. */
q: string;
};
header?: {
+2 -2
View File
@@ -162,8 +162,8 @@ export type BgpCommunityPatch = Partial<BgpCommunityCreate>
export type CommunitiesResponse = Page<BgpCommunity>
// ---- Lookup (GET /v1/lookup) ----
/** @see https://reui.io/preview/base/stats-12 — KPI summary on /lookup */
export type LookupQueryKind = 'ip' | 'domain'
/** @see https://reui.io/preview/base/wizard-2 — lookup membership wizard */
export type LookupQueryKind = 'ip' | 'domain' | 'cidr'
export type LookupLayer = 'entry' | 'snapshot'
export type LookupMatchKind = 'ip_range' | 'domain' | 'prefix'
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -789,7 +789,7 @@ components:
LookupQueryKind:
type: string
enum: [ip, domain]
enum: [ip, domain, cidr]
description: Определённый тип запроса после нормализации.
LookupLayer:
@@ -864,7 +864,7 @@ components:
$ref: "#/components/schemas/LookupQueryKind"
normalized:
type: string
description: Нормализованный IP или FQDN.
description: Нормализованный IP, masked CIDR или FQDN.
matched:
type: boolean
description: true, если есть хотя бы одно совпадение.
@@ -2028,12 +2028,14 @@ paths:
/v1/lookup:
get:
tags: [Lookup]
summary: Проверка IP или домена в списках
summary: Проверка IP, CIDR или домена в списках
description: |
Быстрая membership-проверка по tenant:
- **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot`
(все module prefix snapshots, `Prefix.Contains`);
- **CIDR** — слой `entry` (`IP_RANGES`, равенство masked-префикса или покрытие
запрошенной сети) и слой `snapshot` (то же правило);
- **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot`
(префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть);
затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка
@@ -2054,7 +2056,7 @@ paths:
type: string
minLength: 1
maxLength: 253
description: IP-адрес или FQDN для проверки.
description: IP-адрес, CIDR или FQDN для проверки.
responses:
"200":
description: Результат проверки (в т.ч. matched=false при отсутствии совпадений).
+1 -1
View File
@@ -27,7 +27,7 @@ func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) {
res, err := lookup.Lookup(r.Context(), s.store, a.TenantID, q)
if err != nil {
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, CIDR, or FQDN")
return
}
writeStoreErr(w, err)
+90 -2
View File
@@ -19,6 +19,7 @@ type QueryKind string
const (
KindIP QueryKind = "ip"
KindDomain QueryKind = "domain"
KindCIDR QueryKind = "cidr"
)
// Layer identifies which data source produced a match.
@@ -69,7 +70,7 @@ type Result struct {
// 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, CIDR, or FQDN) is present in tenant lists (entries + snapshots).
// 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)
@@ -109,10 +110,17 @@ func LookupWithResolver(
if err := lookupIP(st, tenantID, addr, out, commByID, ""); err != nil {
return nil, err
}
} else if pfx, err := netip.ParsePrefix(raw); err == nil {
masked := pfx.Masked()
out.QueryKind = KindCIDR
out.Normalized = masked.String()
if err := lookupCIDR(st, tenantID, masked, out, commByID); err != nil {
return nil, err
}
} else {
fqdn, ok := normalizeFQDN(raw)
if !ok {
return nil, fmt.Errorf("%w: query must be an IP address or FQDN", store.ErrInvalidInput)
return nil, fmt.Errorf("%w: query must be an IP address, CIDR, or FQDN", store.ErrInvalidInput)
}
out.QueryKind = KindDomain
out.Normalized = fqdn
@@ -250,6 +258,86 @@ func lookupIP(
return nil
}
// prefixCoversQuery reports whether listPfx equals query or fully contains it.
func prefixCoversQuery(listPfx, query netip.Prefix) bool {
listPfx = listPfx.Masked()
query = query.Masked()
if listPfx == query {
return true
}
return listPfx.Contains(query.Addr()) && listPfx.Bits() <= query.Bits()
}
func lookupCIDR(
st store.Backend,
tenantID string,
query netip.Prefix,
out *Result,
commByID map[string]*store.Community,
) error {
for _, mod := range st.ListModules(tenantID) {
if mod == nil {
continue
}
if mod.Type == "IP_RANGES" {
entries, err := st.ListIPRangeEntries(tenantID, mod.ID)
if err != nil {
return err
}
for _, e := range entries {
if e == nil {
continue
}
pfx, err := netip.ParsePrefix(strings.TrimSpace(e.Prefix))
if err != nil {
continue
}
if !prefixCoversQuery(pfx, query) {
continue
}
out.Matches = append(out.Matches, decorateMatch(Match{
Layer: LayerEntry,
ModuleID: mod.ID,
ModuleName: mod.Name,
ModuleType: mod.Type,
MatchKind: MatchIPRange,
MatchedValue: e.Prefix,
EntryID: e.ID,
CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID),
}, commByID))
}
}
snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mod.ID)
if err != nil {
return err
}
if !ok || snap == nil {
continue
}
for _, row := range snap.Prefixes {
pfx, err := netip.ParsePrefix(strings.TrimSpace(row.Prefix))
if err != nil {
continue
}
if !prefixCoversQuery(pfx, query) {
continue
}
out.Matches = append(out.Matches, decorateMatch(Match{
Layer: LayerSnapshot,
ModuleID: mod.ID,
ModuleName: mod.Name,
ModuleType: mod.Type,
MatchKind: MatchPrefix,
MatchedValue: row.Prefix,
Source: row.Source,
CommunityID: row.CommunityID,
}, commByID))
}
}
return nil
}
func lookupDomain(st store.Backend, tenantID, fqdn string, out *Result, commByID map[string]*store.Community) error {
matchedModuleIDs := make(map[string]*store.Module)
+54
View File
@@ -220,6 +220,60 @@ func TestLookupDomainResolvedIPAgainstRanges(t *testing.T) {
}
}
func TestLookupCIDREntryAndSnapshot(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, modIP, _, _ := m.DemoIDs()
comms, _ := m.ListCommunities(tenant)
cid := comms[0].ID
e, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{
Prefix: "203.0.113.0/24",
CommunityID: &cid,
})
if err != nil {
t.Fatal(err)
}
if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash-cidr", []store.PrefixRow{
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"},
}); err != nil {
t.Fatal(err)
}
res, err := Lookup(context.Background(), m, tenant, "203.0.113.0/24")
if err != nil {
t.Fatal(err)
}
if res.QueryKind != KindCIDR || res.Normalized != "203.0.113.0/24" {
t.Fatalf("kind/normalized: %+v", res)
}
if !res.Matched || res.MatchCount < 2 {
t.Fatalf("expected entry+snapshot, got %+v", res)
}
var entryHit, snapHit bool
for _, hit := range res.Matches {
if hit.Layer == LayerEntry && hit.EntryID == e.ID {
entryHit = true
}
if hit.Layer == LayerSnapshot && hit.MatchedValue == "203.0.113.0/24" {
snapHit = true
}
}
if !entryHit || !snapHit {
t.Fatalf("entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches)
}
// Narrower query covered by wider entry.
res2, err := Lookup(context.Background(), m, tenant, "203.0.113.128/25")
if err != nil {
t.Fatal(err)
}
if res2.QueryKind != KindCIDR || !res2.Matched {
t.Fatalf("expected cover match: %+v", res2)
}
}
func TestLookupNoMatch(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()