diff --git a/apps/web/src/components/dashboard/dashboard-quick-links.tsx b/apps/web/src/components/dashboard/dashboard-quick-links.tsx index 56f7bf1..9dc5d64 100644 --- a/apps/web/src/components/dashboard/dashboard-quick-links.tsx +++ b/apps/web/src/components/dashboard/dashboard-quick-links.tsx @@ -1,8 +1,16 @@ -import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react' +import { Gauge, Network, Play, Plus, Search, Share2, Tags } from 'lucide-react' import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit' const ACTIONS: QuickActionItem[] = [ + { + id: 'lookup', + title: 'Проверка IP/домена', + description: 'Membership в списках и community (entry + snapshot).', + to: '/lookup', + icon: , + iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground', + }, { id: 'new-module', title: 'Создать модуль', diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 6ed4522..a2f350f 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -10,6 +10,7 @@ import { KeyRound, ServerCog, Shield, + Search, } from 'lucide-react' import { @@ -74,6 +75,7 @@ const NAV_GROUPS: NavGroup[] = [ label: 'Маршрутизация', items: [ { to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' }, + { to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' }, { to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } }, { to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' }, ], diff --git a/apps/web/src/components/lookup/lookup-matches-grid.tsx b/apps/web/src/components/lookup/lookup-matches-grid.tsx new file mode 100644 index 0000000..e8c0754 --- /dev/null +++ b/apps/web/src/components/lookup/lookup-matches-grid.tsx @@ -0,0 +1,129 @@ +import { ColumnDef } from '@tanstack/react-table' +import { useMemo } from 'react' +import { useNavigate } from '@tanstack/react-router' + +import { CategoryBadge } from '@/components/category-badge' +import { DataGridPrimaryCell } from '@/components/data-grid-cell' +import { DataGridCard, DataGridSection } from '@/components/data-grid-shell' +import { Badge } from '@/components/reui/badge' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { useClientDataGrid } from '@/hooks/use-client-data-grid' +import type { LookupMatch } from '@/types/api' + +/** + * Lookup matches grid — data-grid-filtering-2 pattern. + * @see https://reui.io/preview/base/data-grid-filtering-2 + * @see https://reui.io/docs/components/base/badge + */ +export function LookupMatchesGrid({ + items, + isLoading = false, +}: { + items: LookupMatch[] + isLoading?: boolean +}) { + const navigate = useNavigate() + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'layer', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.layer} + + ), + meta: { headerTitle: 'Слой' }, + }, + { + accessorKey: 'module_name', + header: ({ column }) => , + cell: ({ row }) => ( + + ), + meta: { headerTitle: 'Модуль' }, + }, + { + accessorKey: 'matched_value', + header: ({ column }) => , + cell: ({ row }) => ( + + ), + meta: { headerTitle: 'Совпадение' }, + }, + { + id: 'community', + accessorFn: (row) => row.community_title || row.community || '', + header: ({ column }) => , + cell: ({ row }) => { + const title = row.original.community_title?.trim() + const value = row.original.community?.trim() + if (!title && !value) { + return — + } + return ( + + ) + }, + meta: { headerTitle: 'Community' }, + }, + { + id: 'source', + enableSorting: false, + header: 'Источник', + cell: ({ row }) => + row.original.source ? ( + {row.original.source} + ) : ( + — + ), + meta: { headerTitle: 'Источник' }, + }, + ], + [], + ) + + const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ + data: items, + columns, + getSearchText: (row) => + `${row.layer} ${row.module_name} ${row.module_type} ${row.matched_value} ${row.community ?? ''} ${row.community_title ?? ''} ${row.source ?? ''}`, + getRowId: (row) => + `${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`, + }) + + return ( + + + void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } }) + } + /> + + ) +} diff --git a/apps/web/src/components/lookup/lookup-search-form.tsx b/apps/web/src/components/lookup/lookup-search-form.tsx new file mode 100644 index 0000000..63385a1 --- /dev/null +++ b/apps/web/src/components/lookup/lookup-search-form.tsx @@ -0,0 +1,76 @@ +import { useState, type FormEvent } from 'react' +import { Search } from 'lucide-react' + +import { Button } from '@evobgp/ui/components/button' +import { Field, FieldLabel } from '@evobgp/ui/components/field' +import { + InputGroup, + 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 + * @see https://reui.io/docs/components/base/frame + */ +export function LookupSearchForm({ + initialQuery = '', + isPending = false, + onSubmit, +}: { + initialQuery?: string + isPending?: boolean + onSubmit: (q: string) => void +}) { + const [value, setValue] = useState(initialQuery) + + function handleSubmit(e: FormEvent) { + e.preventDefault() + const q = value.trim() + if (!q) return + onSubmit(q) + } + + return ( + + + Проверка списка + + IP или FQDN — поиск в entries и материализованных snapshots с community. + + + + + + IP или домен + + + + + setValue(e.target.value)} + placeholder="8.8.8.8 или example.com" + autoComplete="off" + autoFocus + /> + + + + Проверить + + + + + ) +} diff --git a/apps/web/src/components/lookup/lookup-summary-kpi.tsx b/apps/web/src/components/lookup/lookup-summary-kpi.tsx new file mode 100644 index 0000000..b2839ed --- /dev/null +++ b/apps/web/src/components/lookup/lookup-summary-kpi.tsx @@ -0,0 +1,50 @@ +import { 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 items: KpiStatItem[] = [ + { + id: 'matched', + label: 'Результат', + value: data.matched ? 'Найдено' : 'Не найдено', + hint: data.normalized, + icon: , + 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: , + iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground', + }, + { + id: 'snapshot', + label: 'Слой snapshot', + value: snapshotCount, + hint: 'материализация', + icon: , + iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground', + }, + ] + + return ( + + ) +} diff --git a/apps/web/src/queries/lookup.ts b/apps/web/src/queries/lookup.ts new file mode 100644 index 0000000..16b49cc --- /dev/null +++ b/apps/web/src/queries/lookup.ts @@ -0,0 +1,21 @@ +import { queryOptions } from '@tanstack/react-query' + +import { apiJSON } from '@/lib/api-client' +import type { LookupResponse } from '@/types/api' + +export const lookupKeys = { + all: ['lookup'] as const, + query: (q: string) => [...lookupKeys.all, q] as const, +} + +/** GET /v1/lookup?q= — dual-layer membership (entry + snapshot). */ +export function lookupQueryOptions(q: string) { + const trimmed = q.trim() + return queryOptions({ + queryKey: lookupKeys.query(trimmed), + queryFn: () => + apiJSON(`/v1/lookup?q=${encodeURIComponent(trimmed)}`), + enabled: trimmed.length > 0, + staleTime: 15_000, + }) +} diff --git a/apps/web/src/routes/_auth/lookup.tsx b/apps/web/src/routes/_auth/lookup.tsx new file mode 100644 index 0000000..6ac6657 --- /dev/null +++ b/apps/web/src/routes/_auth/lookup.tsx @@ -0,0 +1,86 @@ +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 { 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 + * @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 + */ +export const Route = createFileRoute('/_auth/lookup')({ + component: LookupComponent, + validateSearch: (search: Record) => ({ + q: typeof search.q === 'string' ? search.q : '', + }), +}) + +function LookupComponent() { + const { q } = useSearch({ from: '/_auth/lookup' }) + const navigate = Route.useNavigate() + const lookupQ = useQuery(lookupQueryOptions(q)) + + return ( + + + + void navigate({ search: { q: next } })} + /> + + {!q.trim() ? ( + } + title="Введите IP или домен" + description="Например 8.8.8.8 или example.com — проверка по сырым entries и материализованным префиксам." + /> + ) : ( + void lookupQ.refetch()} + skeleton={ + + + + + } + > + {(data) => ( + + + {data.matched ? ( + + ) : ( + } + title="Не найдено в списках" + description={`«${data.normalized}» отсутствует в entries и snapshots tenant.`} + /> + )} + + )} + + )} + + ) +} diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 193e38b..d0f908f 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -151,6 +151,35 @@ export type BgpCommunityCreate = { export type BgpCommunityPatch = Partial export type CommunitiesResponse = Page +// ---- Lookup (GET /v1/lookup) ---- +/** @see https://reui.io/preview/base/stats-12 — KPI summary on /lookup */ +export type LookupQueryKind = 'ip' | 'domain' +export type LookupLayer = 'entry' | 'snapshot' +export type LookupMatchKind = 'ip_range' | 'domain' | 'prefix' + +export type LookupMatch = { + layer: LookupLayer + module_id: string + module_name: string + module_type: ModuleType + match_kind: LookupMatchKind + matched_value: string + entry_id?: string + source?: string + community_id?: string | null + community?: string + community_title?: string +} + +export type LookupResponse = { + query: string + query_kind: LookupQueryKind + normalized: string + matched: boolean + match_count: number + matches: LookupMatch[] +} + // ---- Peers ---- export type PeerSessionOnSpeaker = { speaker_id: string diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index ac905f4..ea812b4 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/counted-line-tabs.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/kpi-stat-grid.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-2.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rule-create-dialog.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/reui-kit/detail-panel.tsx","./src/components/reui-kit/filter-utils.ts","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-stat-grid.tsx","./src/components/reui-kit/ops-dashboard.tsx","./src/components/reui-kit/quick-action-grid.tsx","./src/components/reui-kit/resource-page.tsx","./src/components/reui-kit/settings-shell.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/appearance-settings-tab.tsx","./src/components/settings/connection-settings-tab.tsx","./src/components/settings/sections-settings-tab.tsx","./src/components/settings/session-settings-tab.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-page-shell.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/settings/settings-tabs-data.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-file-upload.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/counted-line-tabs.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/kpi-stat-grid.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-2.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rule-create-dialog.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-summary-kpi.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/reui-kit/detail-panel.tsx","./src/components/reui-kit/filter-utils.ts","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-stat-grid.tsx","./src/components/reui-kit/ops-dashboard.tsx","./src/components/reui-kit/quick-action-grid.tsx","./src/components/reui-kit/resource-page.tsx","./src/components/reui-kit/settings-shell.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/appearance-settings-tab.tsx","./src/components/settings/connection-settings-tab.tsx","./src/components/settings/sections-settings-tab.tsx","./src/components/settings/session-settings-tab.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-page-shell.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/settings/settings-tabs-data.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-file-upload.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/lookup.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/lookup.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index 716f971..5080db7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,6 +33,15 @@ - `POST /v1/modules`, `PATCH /v1/modules/{module_id}`, `DELETE /v1/modules/{module_id}` - `GET|POST|PATCH|DELETE` для `.../cdn-sources`, `.../as-entries`, `.../domain-entries`, `.../ip-range-entries` - `POST /v1/modules/{module_id}/refresh` +- `GET /v1/router-lists/catalog` — агрегированный каталог модулей/entries/communities + +### Lookup + +- `GET /v1/lookup?q=` — быстрая проверка IP или FQDN в списках (viewer+). + - Слой `entry`: `IP_RANGES` (`CIDR.Contains`) / `DOMAINS` (нормализованный FQDN). + - Слой `snapshot`: материализованные `module_prefix_snapshot` (для IP — Contains по всем модулям; для домена — `source=domain` у matched DOMAINS-модулей). + - В каждом матче — community (`community_id` / значение / title). + - Live DoH не выполняется. Контракт: OpenAPI `lookupMembership`. ### DoH profiles diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c870070..4897344 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -27,6 +27,8 @@ tags: description: Liveness, readiness и метаданные сборки. Обычно без чувствительных данных; доступ может быть шире. - name: Modules description: Экземпляры модулей префиксов (AS, CDN, домены, статические IP-диапазоны) и вложенные записи. Чтение - viewer+; изменение - editor+. + - name: Lookup + description: Быстрая проверка membership IP/FQDN в списках (entries + module prefix snapshots) и community. Чтение - viewer+. - name: DoH profiles description: Профили DNS-over-HTTPS для модулей типа домены. Секрет в ответах не возвращается. - name: Communities @@ -221,6 +223,12 @@ components: application/problem+json: schema: $ref: "#/components/schemas/Problem" + BadRequest: + description: Некорректный запрос (пустой или невалидный параметр). + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" Forbidden: description: Недостаточно прав для операции. content: @@ -751,6 +759,90 @@ components: description: Человекочитаемое название для UI и фильтров. additionalProperties: true + LookupQueryKind: + type: string + enum: [ip, domain] + description: Определённый тип запроса после нормализации. + + LookupLayer: + type: string + enum: [entry, snapshot] + description: | + `entry` — сырые IP_RANGES / DOMAINS entries; + `snapshot` — материализованные префиксы `module_prefix_snapshot`. + + LookupMatchKind: + type: string + enum: [ip_range, domain, prefix] + description: Вид совпадения (entry CIDR, entry FQDN или snapshot prefix). + + LookupMatch: + type: object + required: + - layer + - module_id + - module_name + - module_type + - match_kind + - matched_value + properties: + layer: + $ref: "#/components/schemas/LookupLayer" + module_id: + $ref: "#/components/schemas/ResourceId" + module_name: + type: string + module_type: + $ref: "#/components/schemas/ModuleType" + match_kind: + $ref: "#/components/schemas/LookupMatchKind" + matched_value: + type: string + description: CIDR, FQDN или prefix, с которым совпал запрос. + entry_id: + type: string + description: ID entry (только для layer=entry). + source: + type: string + description: Источник строки snapshot (ip_range, domain, as, cdn, …). + community_id: + type: ["string", "null"] + community: + type: string + description: Техническое значение BGP community. + community_title: + type: string + description: Человекочитаемое название community. + + LookupResponse: + type: object + required: + - query + - query_kind + - normalized + - matched + - match_count + - matches + properties: + query: + type: string + description: Исходная строка запроса. + query_kind: + $ref: "#/components/schemas/LookupQueryKind" + normalized: + type: string + description: Нормализованный IP или FQDN. + matched: + type: boolean + description: true, если есть хотя бы одно совпадение. + match_count: + type: integer + minimum: 0 + matches: + type: array + items: + $ref: "#/components/schemas/LookupMatch" + BgpPeer: type: object required: @@ -1780,6 +1872,47 @@ paths: default: $ref: "#/components/responses/DefaultProblem" + /v1/lookup: + get: + tags: [Lookup] + summary: Проверка IP или домена в списках + description: | + Быстрая membership-проверка по tenant: + + - **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot` + (все module prefix snapshots, `Prefix.Contains`); + - **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot` + (префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть). + + Community на матче: `entry.community_id || module.default_community_id` (entry) + или `PrefixRow.community_id` (snapshot), с join к справочнику communities. + + Live DoH resolve не выполняется — только уже материализованный snapshot. + operationId: lookupMembership + parameters: + - $ref: "#/components/parameters/TenantId" + - name: q + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 253 + description: IP-адрес или FQDN для проверки. + responses: + "200": + description: Результат проверки (в т.ч. matched=false при отсутствии совпадений). + content: + application/json: + schema: + $ref: "#/components/schemas/LookupResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + default: + $ref: "#/components/responses/DefaultProblem" + /v1/router-lists/catalog: get: tags: [Modules] diff --git a/docs/ui-design-contract.md b/docs/ui-design-contract.md index 3223602..ef3c9d3 100644 --- a/docs/ui-design-contract.md +++ b/docs/ui-design-contract.md @@ -25,6 +25,7 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad | Auth | `auth-13` | https://reui.io/preview/base/auth-13 | | Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 | | Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 | +| Lookup | `/lookup` — Frame form + `KpiStatGrid` + DataGrid | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/empty-state-2 | ## Kit API (`reui-kit/`) diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 3021e7a..34b9c1b 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -54,6 +54,7 @@ func (s *Server) registerRoutes() { func (s *Server) registerV1(m *http.ServeMux) { m.HandleFunc("GET /modules", s.handleListModules) + m.HandleFunc("GET /lookup", s.handleLookup) m.HandleFunc("GET /router-lists/catalog", s.handleRouterListsCatalog) m.HandleFunc("GET /modules/{module_id}", s.handleGetModule) m.HandleFunc("GET /peers", s.handleListPeers) diff --git a/internal/httpapi/routes_lookup.go b/internal/httpapi/routes_lookup.go new file mode 100644 index 0000000..2ea2d90 --- /dev/null +++ b/internal/httpapi/routes_lookup.go @@ -0,0 +1,37 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + "evobgp/internal/lookup" + "evobgp/internal/store" +) + +// handleLookup implements GET /v1/lookup?q= (operationId: lookupMembership). +func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth") + return + } + if !s.requireAtLeast(w, a, "viewer") { + return + } + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + writeProblem(w, http.StatusBadRequest, "Bad Request", "query parameter q is required") + return + } + res, err := lookup.Lookup(s.store, a.TenantID, q) + if err != nil { + if errors.Is(err, store.ErrInvalidInput) { + writeProblem(w, http.StatusBadRequest, "Bad Request", err.Error()) + return + } + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, res) +} diff --git a/internal/httpapi/routes_lookup_test.go b/internal/httpapi/routes_lookup_test.go new file mode 100644 index 0000000..c1071a6 --- /dev/null +++ b/internal/httpapi/routes_lookup_test.go @@ -0,0 +1,79 @@ +package httpapi + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "evobgp/internal/store" +) + +func TestLookupMembershipHTTP(t *testing.T) { + srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + tenant, _, modIP, _, _ := srv.Store().DemoIDs() + mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor") + + comms, err := srv.Store().ListCommunities(tenant) + if err != nil || len(comms) == 0 { + t.Fatal("demo community") + } + cid := comms[0].ID + if _, err := srv.Store().CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{ + Prefix: "198.51.100.0/24", + CommunityID: &cid, + }); err != nil { + t.Fatal(err) + } + if err := srv.Store().SetModulePrefixSnapshot(tenant, modIP, "t", []store.PrefixRow{ + {Prefix: "198.51.100.0/24", CommunityID: &cid, Source: "ip_range"}, + }); err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/lookup?q="+url.QueryEscape("198.51.100.7"), nil) + req.Header.Set("Authorization", "Bearer edkey") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + var body struct { + Matched bool `json:"matched"` + MatchCount int `json:"match_count"` + QueryKind string `json:"query_kind"` + Matches []struct { + Layer string `json:"layer"` + } `json:"matches"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if !body.Matched || body.QueryKind != "ip" || body.MatchCount < 2 { + t.Fatalf("unexpected body: %+v", body) + } + + reqBad, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/lookup?q=", nil) + reqBad.Header.Set("Authorization", "Bearer edkey") + respBad, err := ts.Client().Do(reqBad) + if err != nil { + t.Fatal(err) + } + defer func() { _ = respBad.Body.Close() }() + if respBad.StatusCode != http.StatusBadRequest { + t.Fatalf("empty q: status %d", respBad.StatusCode) + } +} diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go new file mode 100644 index 0000000..3424a4b --- /dev/null +++ b/internal/lookup/lookup.go @@ -0,0 +1,290 @@ +// Package lookup implements dual-layer membership checks for IP addresses and FQDNs +// against module entries and materialized prefix snapshots. +package lookup + +import ( + "fmt" + "net/netip" + "strings" + "unicode" + + "evobgp/internal/store" +) + +// QueryKind is the normalized kind of a lookup query. +type QueryKind string + +const ( + KindIP QueryKind = "ip" + KindDomain QueryKind = "domain" +) + +// Layer identifies which data source produced a match. +type Layer string + +const ( + LayerEntry Layer = "entry" + LayerSnapshot Layer = "snapshot" +) + +// MatchKind is the concrete match type within a layer. +type MatchKind string + +const ( + MatchIPRange MatchKind = "ip_range" + MatchDomain MatchKind = "domain" + MatchPrefix MatchKind = "prefix" +) + +// Match is one membership hit (entry or snapshot) with resolved community fields. +type Match struct { + Layer Layer `json:"layer"` + ModuleID string `json:"module_id"` + ModuleName string `json:"module_name"` + ModuleType string `json:"module_type"` + MatchKind MatchKind `json:"match_kind"` + MatchedValue string `json:"matched_value"` + EntryID string `json:"entry_id,omitempty"` + Source string `json:"source,omitempty"` + CommunityID *string `json:"community_id,omitempty"` + Community string `json:"community,omitempty"` + CommunityTitle string `json:"community_title,omitempty"` +} + +// Result is the full lookup response payload. +type Result struct { + Query string `json:"query"` + QueryKind QueryKind `json:"query_kind"` + Normalized string `json:"normalized"` + Matched bool `json:"matched"` + MatchCount int `json:"match_count"` + Matches []Match `json:"matches"` +} + +// Lookup checks whether q (IP or FQDN) is present in tenant lists (entries + snapshots). +func Lookup(st store.Backend, tenantID, q string) (*Result, error) { + raw := strings.TrimSpace(q) + if raw == "" { + return nil, fmt.Errorf("%w: empty query", store.ErrInvalidInput) + } + + comms, err := st.ListCommunities(tenantID) + if err != nil { + return nil, err + } + commByID := make(map[string]*store.Community, len(comms)) + for _, c := range comms { + if c != nil { + commByID[c.ID] = c + } + } + + out := &Result{ + Query: raw, + Matches: make([]Match, 0), + } + + if addr, err := netip.ParseAddr(raw); err == nil { + out.QueryKind = KindIP + out.Normalized = addr.String() + if err := lookupIP(st, tenantID, addr, 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) + } + out.QueryKind = KindDomain + out.Normalized = fqdn + if err := lookupDomain(st, tenantID, fqdn, out, commByID); err != nil { + return nil, err + } + } + + out.MatchCount = len(out.Matches) + out.Matched = out.MatchCount > 0 + return out, nil +} + +func lookupIP(st store.Backend, tenantID string, addr netip.Addr, 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 !pfx.Contains(addr) { + 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 !pfx.Contains(addr) { + 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) + + for _, mod := range st.ListModules(tenantID) { + if mod == nil || mod.Type != "DOMAINS" { + continue + } + entries, err := st.ListDomainEntries(tenantID, mod.ID) + if err != nil { + return err + } + for _, e := range entries { + if e == nil { + continue + } + norm, ok := normalizeFQDN(e.FQDN) + if !ok || norm != fqdn { + continue + } + matchedModuleIDs[mod.ID] = mod + out.Matches = append(out.Matches, decorateMatch(Match{ + Layer: LayerEntry, + ModuleID: mod.ID, + ModuleName: mod.Name, + ModuleType: mod.Type, + MatchKind: MatchDomain, + MatchedValue: e.FQDN, + EntryID: e.ID, + CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID), + }, commByID)) + } + } + + for mid, mod := range matchedModuleIDs { + snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mid) + if err != nil { + return err + } + if !ok || snap == nil { + continue + } + for _, row := range snap.Prefixes { + if !strings.EqualFold(strings.TrimSpace(row.Source), "domain") { + 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 resolveCommunityID(entryID, defaultID *string) *string { + if entryID != nil && strings.TrimSpace(*entryID) != "" { + return entryID + } + if defaultID != nil && strings.TrimSpace(*defaultID) != "" { + return defaultID + } + return nil +} + +func decorateMatch(m Match, commByID map[string]*store.Community) Match { + if m.CommunityID == nil { + return m + } + c, ok := commByID[*m.CommunityID] + if !ok || c == nil { + return m + } + m.Community = c.Community + m.CommunityTitle = c.Title + return m +} + +// normalizeFQDN lowercases, trims trailing dots, and validates a simple hostname shape. +func normalizeFQDN(s string) (string, bool) { + s = strings.TrimSpace(s) + s = strings.TrimSuffix(s, ".") + s = strings.ToLower(s) + if s == "" || len(s) > 253 { + return "", false + } + if strings.ContainsAny(s, " /\\\t\n") { + return "", false + } + if _, err := netip.ParseAddr(s); err == nil { + return "", false + } + labels := strings.Split(s, ".") + if len(labels) < 2 { + return "", false + } + for _, label := range labels { + if label == "" || len(label) > 63 { + return "", false + } + if label[0] == '-' || label[len(label)-1] == '-' { + return "", false + } + for _, r := range label { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' { + continue + } + return "", false + } + } + return s, true +} diff --git a/internal/lookup/lookup_test.go b/internal/lookup/lookup_test.go new file mode 100644 index 0000000..c88f1a5 --- /dev/null +++ b/internal/lookup/lookup_test.go @@ -0,0 +1,206 @@ +package lookup + +import ( + "errors" + "testing" + + "evobgp/internal/store" +) + +func TestLookupIPEntryAndSnapshot(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, modIP, _, _ := m.DemoIDs() + + cid := "" + comms, err := m.ListCommunities(tenant) + if err != nil || len(comms) == 0 { + t.Fatal("expected demo community") + } + cid = comms[0].ID + + def := cid + if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &def}); err != nil { + t.Fatal(err) + } + + entryComm := cid + e, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{ + Prefix: "203.0.113.0/24", + CommunityID: &entryComm, + }) + if err != nil { + t.Fatal(err) + } + + if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash1", []store.PrefixRow{ + {Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"}, + }); err != nil { + t.Fatal(err) + } + + res, err := Lookup(m, tenant, "203.0.113.10") + if err != nil { + t.Fatal(err) + } + if res.QueryKind != KindIP || res.Normalized != "203.0.113.10" { + t.Fatalf("kind/normalized: %+v", res) + } + if !res.Matched || res.MatchCount < 2 { + t.Fatalf("expected entry+snapshot matches, got %+v", res) + } + + var entryHit, snapHit bool + for _, hit := range res.Matches { + if hit.Layer == LayerEntry && hit.EntryID == e.ID { + entryHit = true + if hit.Community != "demo-comm" || hit.CommunityTitle != "Demo" { + t.Fatalf("entry community: %+v", hit) + } + } + if hit.Layer == LayerSnapshot && hit.MatchedValue == "203.0.113.0/24" { + snapHit = true + } + } + if !entryHit || !snapHit { + t.Fatalf("missing layers entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches) + } +} + +func TestLookupIPCommunityFallback(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, modIP, _, _ := m.DemoIDs() + comms, _ := m.ListCommunities(tenant) + cid := comms[0].ID + if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &cid}); err != nil { + t.Fatal(err) + } + if _, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{Prefix: "10.0.0.0/8"}); err != nil { + t.Fatal(err) + } + + res, err := Lookup(m, tenant, "10.1.2.3") + if err != nil { + t.Fatal(err) + } + if !res.Matched { + t.Fatal("expected match") + } + found := false + for _, hit := range res.Matches { + if hit.Layer == LayerEntry { + found = true + if hit.CommunityID == nil || *hit.CommunityID != cid { + t.Fatalf("expected default community, got %+v", hit) + } + if hit.Community != "demo-comm" { + t.Fatalf("community value: %+v", hit) + } + } + } + if !found { + t.Fatal("no entry match") + } +} + +func TestLookupDomainEntryAndSnapshot(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, _, _, _ := m.DemoIDs() + + mod, err := m.CreateModule(tenant, &store.Module{ + Type: "DOMAINS", + Name: "demo-domains", + Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + comms, _ := m.ListCommunities(tenant) + cid := comms[0].ID + + e, err := m.CreateDomainEntry(tenant, mod.ID, &store.DomainEntry{ + FQDN: "Example.COM.", + CommunityID: &cid, + }) + if err != nil { + t.Fatal(err) + } + if err := m.SetModulePrefixSnapshot(tenant, mod.ID, "hash-d", []store.PrefixRow{ + {Prefix: "198.51.100.1/32", CommunityID: &cid, Source: "domain"}, + {Prefix: "203.0.113.9/32", CommunityID: &cid, Source: "other"}, + }); err != nil { + t.Fatal(err) + } + + res, err := Lookup(m, tenant, "example.com") + if err != nil { + t.Fatal(err) + } + if res.QueryKind != KindDomain || res.Normalized != "example.com" { + t.Fatalf("kind/normalized: %+v", res) + } + if !res.Matched { + t.Fatal("expected match") + } + + var entryHit, snapHit, otherSnap bool + for _, hit := range res.Matches { + if hit.Layer == LayerEntry && hit.EntryID == e.ID { + entryHit = true + } + if hit.Layer == LayerSnapshot && hit.MatchedValue == "198.51.100.1/32" { + snapHit = true + } + if hit.MatchedValue == "203.0.113.9/32" { + otherSnap = true + } + } + if !entryHit || !snapHit { + t.Fatalf("entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches) + } + if otherSnap { + t.Fatal("non-domain snapshot source should be excluded") + } +} + +func TestLookupNoMatch(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, _, _, _ := m.DemoIDs() + + res, err := Lookup(m, tenant, "192.0.2.1") + if err != nil { + t.Fatal(err) + } + if res.Matched || res.MatchCount != 0 || len(res.Matches) != 0 { + t.Fatalf("expected empty: %+v", res) + } +} + +func TestLookupInvalid(t *testing.T) { + m := store.NewMemory() + m.SeedDemo() + tenant, _, _, _, _ := m.DemoIDs() + + _, err := Lookup(m, tenant, "") + if !errors.Is(err, store.ErrInvalidInput) { + t.Fatalf("empty: %v", err) + } + _, err = Lookup(m, tenant, "not a host") + if !errors.Is(err, store.ErrInvalidInput) { + t.Fatalf("spaces: %v", err) + } + _, err = Lookup(m, tenant, "localhost") + if !errors.Is(err, store.ErrInvalidInput) { + t.Fatalf("single label: %v", err) + } +} + +func TestNormalizeFQDN(t *testing.T) { + got, ok := normalizeFQDN(" Example.COM. ") + if !ok || got != "example.com" { + t.Fatalf("got %q ok=%v", got, ok) + } +}