feat(lookup): enhance lookup functionality to support CIDR queries
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 при отсутствии совпадений).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user