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>
This commit is contained in:
Denozordec
2026-07-31 13:20:18 +07:00
co-authored by Cursor
parent b13c233679
commit f45b2bed90
12 changed files with 1240 additions and 69 deletions
@@ -0,0 +1,199 @@
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 {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
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 — choose module + community and create entry.
* @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/docs/components/base/frame
*/
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 (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Добавить в списки</FrameTitle>
<FrameDescription>
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
</FrameDescription>
</FrameHeader>
<FramePanel 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'}
/>
</FramePanel>
<FrameFooter className="flex flex-wrap items-center justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
Назад
</Button>
<LoadingButton
loading={saving}
onClick={() => void handleAdd()}
disabled={!moduleId || (data.query_kind !== 'domain' && !communityId)}
>
Добавить
</LoadingButton>
</FrameFooter>
</Frame>
)
}
@@ -44,13 +44,13 @@ export function LookupSearchForm({
<FrameHeader>
<FrameTitle>Проверка списка</FrameTitle>
<FrameDescription>
IP или FQDN поиск в entries и материализованных snapshots с community.
IP, CIDR или 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>
<FieldLabel htmlFor="lookup-q">IP, CIDR или домен</FieldLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<Search aria-hidden />
@@ -60,7 +60,7 @@ export function LookupSearchForm({
name="q"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="8.8.8.8 или example.com"
placeholder="8.8.8.8, 203.0.113.0/24 или example.com"
autoComplete="off"
autoFocus
/>
@@ -0,0 +1,380 @@
import { useEffect, useState } 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 { LookupSummaryKpi } from '@/components/lookup/lookup-summary-kpi'
import { EmptyState } from '@/components/empty-state'
import { QueryState } from '@/components/query-state'
import {
Alert,
AlertAction,
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 { SectionCardsSkeleton, 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 — check → result → optional add.
* DNA: wizard-2 · surface frame · stepper.
* @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/data-grid-filtering-2
*/
const STEP_QUERY = 1
const STEP_RESULT = 2
const STEP_ADD = 3
const STEP_DONE = 4
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">
<FrameHeader>
<FrameTitle>Мастер проверки</FrameTitle>
<FrameDescription>
Проверка IP / CIDR / домена в списках, затем при необходимости добавление.
</FrameDescription>
</FrameHeader>
<FramePanel>
<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}>
<LookupSearchForm
key={`form-${trimmed}`}
initialQuery={trimmed}
isPending={lookupQ.isFetching}
onSubmit={onSubmitQuery}
/>
{!trimmed ? (
<div className="mt-4">
<EmptyState
icon={<Search className="size-8" />}
title="Введите IP, CIDR или домен"
description="Например 8.8.8.8, 203.0.113.0/24 или example.com — проверка по entries и snapshots."
/>
</div>
) : null}
</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={
<div className="flex flex-col gap-4 md:gap-6">
<SectionCardsSkeleton />
<TableSkeleton rows={5} />
</div>
}
>
{(result) => (
<div className="flex flex-col gap-4 md:gap-6">
<LookupSearchForm
key={`result-form-${trimmed}`}
initialQuery={trimmed}
isPending={lookupQ.isFetching}
onSubmit={onSubmitQuery}
/>
<LookupSummaryKpi data={result} />
{result.matched ? (
<>
<Alert variant="success">
<Check className="size-4" />
<AlertTitle>Уже есть в списках</AlertTitle>
<AlertDescription>
«{result.normalized}» найден в entries и/или snapshots
({result.match_count} совпад.).
</AlertDescription>
<AlertAction>
<Button
type="button"
variant="outline"
size="xs"
onClick={resetToQuery}
>
Новая проверка
</Button>
</AlertAction>
</Alert>
<LookupMatchesGrid
items={result.matches}
isLoading={lookupQ.isFetching}
/>
</>
) : canWrite ? (
<Alert variant="info">
<Plus className="size-4" />
<AlertTitle>Не найдено в списках</AlertTitle>
<AlertDescription>
«{result.normalized}» отсутствует. Добавить запись?
</AlertDescription>
<AlertAction>
<Button
type="button"
size="xs"
onClick={() => void goToAdd()}
>
Добавить
</Button>
<Button
type="button"
variant="outline"
size="xs"
onClick={resetToQuery}
>
Новая проверка
</Button>
</AlertAction>
</Alert>
) : (
<Alert variant="warning">
<ShieldAlert className="size-4" />
<AlertTitle>Не найдено</AlertTitle>
<AlertDescription>
«{result.normalized}» отсутствует в списках. У вас нет
права добавлять записи (нужно bgp:modules:write).
</AlertDescription>
<AlertAction>
<Button
type="button"
variant="outline"
size="xs"
onClick={resetToQuery}
>
Новая проверка
</Button>
</AlertAction>
</Alert>
)}
</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>
<AlertAction>
<Button
type="button"
size="xs"
onClick={() => setStep(STEP_RESULT)}
>
К результату
</Button>
<Button
type="button"
variant="outline"
size="xs"
onClick={resetToQuery}
>
Новая проверка
</Button>
</AlertAction>
</Alert>
{lookupQ.data ? (
<>
<LookupSummaryKpi data={lookupQ.data} />
{lookupQ.data.matched ? (
<LookupMatchesGrid
items={lookupQ.data.matches}
isLoading={lookupQ.isFetching}
/>
) : null}
</>
) : null}
</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>
)
}
+1 -1
View File
@@ -163,7 +163,7 @@ 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'
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()