Compare commits

..
4 Commits
Author SHA1 Message Date
Denozordec 57bcfcd9e1 fix: improve error message for invalid input in lookup functionality
CI / changes (push) Successful in 5s
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / commitlint (push) Skipped
CI / go (push) Successful in 54s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m51s
Updated the error response for invalid input in the handleLookup function to provide a clearer message indicating that the query must be an IP address or FQDN, enhancing user understanding of the input requirements.
2026-07-18 00:05:49 +07:00
Denozordec 1317a73e9a refactor: update AppShell and sidebar components for improved design consistency
CI / changes (push) Successful in 4s
CI / openapi (push) Skipped
CI / web (push) Successful in 1m4s
CI / commitlint (push) Skipped
CI / go (push) Failing after 14s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Removed unused utility functions from the AppShell component and updated the sidebar width to a fixed value for better layout control. Revised the UI design documentation to clarify the shared App Shell chrome specifications and ensure alignment with design standards. Enhanced the sidebar configuration to reflect the new width and styling guidelines.
2026-07-17 23:56:35 +07:00
Denozordec 039d2f3dd9 refactor: update dashboard quick links and app shell for improved layout and functionality
CI / changes (push) Successful in 7s
CI / openapi (push) Skipped
CI / commitlint (push) Skipped
CI / web (push) Successful in 1m4s
CI / go (push) Failing after 17s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Refactored the DashboardQuickLinks component to simplify icon classes for better semantic clarity. Updated the AppShell component to integrate the AppSwitcher and AppsMenu, enhancing navigation and user experience. Adjusted the layout of the app shell header and main content for improved consistency and alignment. Updated UI design documentation to reflect these changes and ensure adherence to shared design standards.
2026-07-17 23:15:00 +07:00
Denozordec 54a0b5b966 feat: add lookup functionality for IP/domain verification and enhance dashboard links
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 22s
CI / web (push) Successful in 49s
CI / go (push) Failing after 16s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Introduced a new lookup feature allowing users to quickly verify IP addresses or domains against community lists. Updated the DashboardQuickLinks component to include a new action for IP/domain checks, enhancing user navigation. Expanded API documentation to include the new lookup endpoint and its response structure, ensuring comprehensive coverage of the feature. Updated UI design documentation to reflect the integration of the lookup functionality.
2026-07-17 20:53:11 +07:00
24 changed files with 1556 additions and 50 deletions
+97
View File
@@ -0,0 +1,97 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
getCurrentApp,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Sidebar app switcher — shared chrome etalon EvoBGP. @see https://reui.io/preview/base/app-shell-12 */
export function AppSwitcher() {
const { isMobile } = useSidebar()
const { config, isLoading } = useAppSwitcherConfig()
const current = getCurrentApp(config)
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
aria-hidden
>
<CurrentIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{current.name}</span>
{current.subtitle ? (
<span className="truncate text-xs text-muted-foreground">
{current.subtitle}
</span>
) : null}
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{isLoading ? 'Загрузка…' : config.menuLabel}
</div>
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem key={app.id} disabled>
<Icon />
{app.name}
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
>
<Icon />
{app.name}
{app.shortcut ? (
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -1,15 +1,24 @@
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'
/** iconClassName: semantic text only (shared chrome with CFDM). @see https://reui.io/preview/base/stats-12 */
const ACTIONS: QuickActionItem[] = [
{
id: 'lookup',
title: 'Проверка IP/домена',
description: 'Membership в списках и community (entry + snapshot).',
to: '/lookup',
icon: <Search aria-hidden />,
iconClassName: 'text-primary',
},
{
id: 'new-module',
title: 'Создать модуль',
description: 'Новый модуль маршрутизации и источники префиксов.',
to: '/modules/new',
icon: <Plus aria-hidden />,
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
iconClassName: 'text-primary',
},
{
id: 'communities',
@@ -17,7 +26,7 @@ const ACTIONS: QuickActionItem[] = [
description: 'Справочник communities для политик экспорта.',
to: '/directories',
icon: <Tags aria-hidden />,
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
iconClassName: 'text-info',
},
{
id: 'network',
@@ -26,7 +35,7 @@ const ACTIONS: QuickActionItem[] = [
to: '/network',
search: { tab: 'overview' },
icon: <Network aria-hidden />,
iconClassName: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
iconClassName: 'text-success',
},
{
id: 'add-peer',
@@ -35,7 +44,7 @@ const ACTIONS: QuickActionItem[] = [
to: '/network',
search: { tab: 'peers' },
icon: <Share2 aria-hidden />,
iconClassName: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
iconClassName: 'text-warning',
},
{
id: 'deploy',
@@ -44,7 +53,7 @@ const ACTIONS: QuickActionItem[] = [
to: '/operations',
search: { tab: 'revisions' },
icon: <Play aria-hidden />,
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
iconClassName: 'text-muted-foreground',
},
{
id: 'monitoring',
@@ -53,7 +62,7 @@ const ACTIONS: QuickActionItem[] = [
to: '/monitoring',
search: { tab: 'system' },
icon: <Gauge aria-hidden />,
iconClassName: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
iconClassName: 'text-destructive',
},
]
+19 -19
View File
@@ -10,6 +10,7 @@ import {
KeyRound,
ServerCog,
Shield,
Search,
} from 'lucide-react'
import {
@@ -41,6 +42,8 @@ import { TooltipProvider } from '@evobgp/ui/components/tooltip'
import { Link, useRouterState } from '@tanstack/react-router'
import type { ComponentType, CSSProperties, ReactNode } from 'react'
import { AppSwitcher } from '@/components/app-switcher'
import { AppsMenu } from '@/components/layout/apps-menu'
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { ModeToggle } from '@/components/mode-toggle'
@@ -74,6 +77,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' },
],
@@ -116,6 +120,11 @@ const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
keywords: [item.to.replace(/^\//, '')],
}))
/**
* Shared ops chrome etalon for CFDM / vps-tracker.
* @see https://reui.io/preview/base/app-shell-12
* @see docs/ui-design-contract.md — Shared App Shell chrome
*/
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const activeItem =
@@ -134,26 +143,13 @@ export function AppShell({ children }: { children: ReactNode }) {
<SidebarProvider
style={
{
'--sidebar-width': '260px',
'--sidebar-width-icon': '62px',
'--header-height': '56px',
'--sidebar-width': '240px',
} as CSSProperties
}
>
<Sidebar collapsible="icon">
<SidebarHeader className="gap-2">
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
B
</div>
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">EvoBGP</span>
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
</div>
</div>
<div className="px-2 group-data-[collapsible=icon]:px-0">
<CommandPalette items={COMMAND_ITEMS} />
</div>
<SidebarHeader>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
{NAV_GROUPS.map((group) => (
@@ -185,8 +181,8 @@ export function AppShell({ children }: { children: ReactNode }) {
<SidebarFooter />
</Sidebar>
<SidebarInset>
<header className="sticky top-0 z-10 flex h-(--header-height) shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<SidebarTrigger />
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
@@ -204,12 +200,16 @@ export function AppShell({ children }: { children: ReactNode }) {
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
<ModeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</SidebarInset>
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
</SidebarProvider>
</TooltipProvider>
)
@@ -0,0 +1,93 @@
import { Link } from '@tanstack/react-router'
import { LayoutGridIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig()
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? 'Загрузка…' : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
className="justify-center text-sm font-medium"
>
Настройки
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -29,9 +29,11 @@ export type CommandPaletteItem = {
interface CommandPaletteProps {
items: CommandPaletteItem[]
className?: string
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
hotkeyOnly?: boolean
}
export function CommandPalette({ items }: CommandPaletteProps) {
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
@@ -68,25 +70,27 @@ export function CommandPalette({ items }: CommandPaletteProps) {
return (
<>
<SidebarGroup className="p-0">
<SidebarGroupContent className="relative">
<Button
type="button"
variant="outline"
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
onClick={() => setOpen(true)}
>
Поиск
</Button>
<Search
aria-hidden
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
/>
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
K
</Kbd>
</SidebarGroupContent>
</SidebarGroup>
{!hotkeyOnly ? (
<SidebarGroup className="p-0">
<SidebarGroupContent className="relative">
<Button
type="button"
variant="outline"
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
onClick={() => setOpen(true)}
>
Поиск
</Button>
<Search
aria-hidden
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
/>
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
K
</Kbd>
</SidebarGroupContent>
</SidebarGroup>
) : null}
<Dialog
open={open}
@@ -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<ColumnDef<LookupMatch>[]>(
() => [
{
accessorKey: 'layer',
header: ({ column }) => <DataGridColumnHeader column={column} title="Слой" />,
cell: ({ row }) => (
<Badge
variant={row.original.layer === 'entry' ? 'info-light' : 'primary-light'}
size="sm"
>
{row.original.layer}
</Badge>
),
meta: { headerTitle: 'Слой' },
},
{
accessorKey: 'module_name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.module_name}
subtitle={row.original.module_type}
accent="primary"
/>
),
meta: { headerTitle: 'Модуль' },
},
{
accessorKey: 'matched_value',
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.matched_value}
subtitle={row.original.match_kind}
accent="mono"
/>
),
meta: { headerTitle: 'Совпадение' },
},
{
id: 'community',
accessorFn: (row) => row.community_title || row.community || '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Community" />,
cell: ({ row }) => {
const title = row.original.community_title?.trim()
const value = row.original.community?.trim()
if (!title && !value) {
return <span className="text-muted-foreground text-sm"></span>
}
return (
<DataGridPrimaryCell
title={title || value || '—'}
subtitle={title && value && title !== value ? value : undefined}
/>
)
},
meta: { headerTitle: 'Community' },
},
{
id: 'source',
enableSorting: false,
header: 'Источник',
cell: ({ row }) =>
row.original.source ? (
<CategoryBadge>{row.original.source}</CategoryBadge>
) : (
<span className="text-muted-foreground text-sm"></span>
),
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 (
<DataGridCard
title="Совпадения"
description="Entries и snapshots · клик по строке открывает модуль"
>
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет совпадений"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Фильтр совпадений…"
onRowClick={(row) =>
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
}
/>
</DataGridCard>
)
}
@@ -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 (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Проверка списка</FrameTitle>
<FrameDescription>
IP или FQDN поиск в entries и материализованных snapshots с community.
</FrameDescription>
</FrameHeader>
<FramePanel>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor="lookup-q">IP или домен</FieldLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<Search aria-hidden />
</InputGroupAddon>
<InputGroupInput
id="lookup-q"
name="q"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="8.8.8.8 или example.com"
autoComplete="off"
autoFocus
/>
</InputGroup>
</Field>
<Button type="submit" disabled={isPending || !value.trim()}>
Проверить
</Button>
</form>
</FramePanel>
</Frame>
)
}
@@ -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: <Radar aria-hidden />,
iconClassName: data.matched
? 'bg-success text-success-foreground [&_svg]:text-success-foreground'
: 'bg-muted text-muted-foreground [&_svg]:text-muted-foreground',
variant: data.matched ? 'default' : 'warning',
},
{
id: 'entry',
label: 'Слой entry',
value: entryCount,
hint: 'сырые списки',
icon: <ListChecks aria-hidden />,
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
},
{
id: 'snapshot',
label: 'Слой snapshot',
value: snapshotCount,
hint: 'материализация',
icon: <Layers aria-hidden />,
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
},
]
return (
<KpiStatGrid
items={items}
aria-label={`Запрос: ${data.query_kind} · ${data.query}`}
/>
)
}
+22
View File
@@ -0,0 +1,22 @@
import {
DEFAULT_APP_SWITCHER_CONFIG,
getAppSwitcherConfig,
getAppUrl as getAppUrlFromConfig,
type AppSwitcherConfig,
} from '@/lib/app-switcher-config'
/** Env-backed app switcher (no DB API in EvoBGP v1). */
export function useAppSwitcherConfig(): {
config: AppSwitcherConfig
isLoading: boolean
} {
return {
config: getAppSwitcherConfig(),
isLoading: false,
}
}
export function useAppUrl(appId: string): string | undefined {
const { config } = useAppSwitcherConfig()
return getAppUrlFromConfig(appId, config ?? DEFAULT_APP_SWITCHER_CONFIG)
}
+102
View File
@@ -0,0 +1,102 @@
import {
ChartBarIcon,
CloudIcon,
GlobeIcon,
LayoutDashboardIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { z } from 'zod'
export const CURRENT_APP_ID = 'evobgp'
const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart'])
export type AppSwitcherIconName = z.infer<typeof appSwitcherIconSchema>
export const APP_SWITCHER_ICONS: Record<AppSwitcherIconName, LucideIcon> = {
server: ServerIcon,
cloud: CloudIcon,
globe: GlobeIcon,
dashboard: LayoutDashboardIcon,
chart: ChartBarIcon,
}
const appSwitcherEntrySchema = z.object({
id: z.string(),
name: z.string(),
subtitle: z.string().optional(),
url: z.string(),
icon: appSwitcherIconSchema.default('server'),
shortcut: z.string().optional(),
})
const appSwitcherConfigSchema = z.object({
menuLabel: z.string().default('Приложения'),
apps: z.array(appSwitcherEntrySchema).min(1),
})
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
/** Shared defaults across ops apps — chrome app switcher. */
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
menuLabel: 'Приложения',
apps: [
{
id: 'vps-tracker',
name: 'VPS Tracker',
subtitle: 'Учёт виртуальных серверов',
url: 'http://192.168.100.67:3001',
icon: 'server',
shortcut: '⌘1',
},
{
id: 'cfdm',
name: 'CF Domain Manager',
subtitle: 'Управление доменами',
url: 'http://192.168.100.67:6363',
icon: 'cloud',
shortcut: '⌘2',
},
{
id: 'evobgp',
name: 'EvoBGP',
subtitle: 'BGP маршрутизация',
url: 'http://192.168.100.67:3000',
icon: 'globe',
shortcut: '⌘3',
},
],
}
export function parseAppSwitcherConfig(raw?: string): AppSwitcherConfig {
if (!raw?.trim()) {
return DEFAULT_APP_SWITCHER_CONFIG
}
try {
const parsed = JSON.parse(raw) as unknown
return appSwitcherConfigSchema.parse(parsed)
} catch (error) {
console.warn('Invalid VITE_APP_SWITCHER, using defaults:', error)
return DEFAULT_APP_SWITCHER_CONFIG
}
}
export function getAppSwitcherConfig(): AppSwitcherConfig {
return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER)
}
export function getAppUrl(
appId: string,
config: AppSwitcherConfig = getAppSwitcherConfig(),
): string | undefined {
return config.apps.find((app) => app.id === appId)?.url
}
export function getCurrentApp(
config: AppSwitcherConfig = getAppSwitcherConfig(),
): AppSwitcherEntry {
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]!
}
+21
View File
@@ -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<LookupResponse>({
queryKey: lookupKeys.query(trimmed),
queryFn: () =>
apiJSON<LookupResponse>(`/v1/lookup?q=${encodeURIComponent(trimmed)}`),
enabled: trimmed.length > 0,
staleTime: 15_000,
})
}
+86
View File
@@ -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<string, unknown>) => ({
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 (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Проверка"
description="Быстрая проверка IP или домена в списках и community (entry + snapshot)"
/>
<LookupSearchForm
key={q}
initialQuery={q}
isPending={lookupQ.isFetching}
onSubmit={(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>
)
}
+29
View File
@@ -151,6 +151,35 @@ export type BgpCommunityCreate = {
export type BgpCommunityPatch = Partial<BgpCommunityCreate>
export type CommunitiesResponse = Page<BgpCommunity>
// ---- Lookup (GET /v1/lookup) ----
/** @see https://reui.io/preview/base/stats-12 — KPI summary on /lookup */
export type LookupQueryKind = 'ip' | 'domain'
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
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_SWITCHER?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
File diff suppressed because one or more lines are too long
+9
View File
@@ -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
+133
View File
@@ -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]
+26 -2
View File
@@ -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/`)
@@ -47,9 +48,32 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
Gating: KV `ui_show_quick_actions` in `global_settings` via `PATCH /v1/settings` (default `true`).
## Shared App Shell chrome
Эталон: **EvoBGP** production [`apps/web/src/components/layout/app-shell.tsx`](../apps/web/src/components/layout/app-shell.tsx) + ReUI [app-shell-12](https://reui.io/preview/base/app-shell-12).
При переключении между vps-tracker / CFDM / EvoBGP меняются **только** sidebar nav labels/hrefs и `main` content. Разметка, ширина, фон и hover chrome идентичны.
| Токен / зона | Значение |
|--------------|----------|
| `SIDEBAR_WIDTH` / `--sidebar-width` | `240px``packages/ui` sidebar + Provider style) |
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` из `globals.css`**без** AppShell `color-mix` override |
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
| Header right | **AppsMenu****SystemMonitorPopover****ModeToggle** (без Search в chrome) |
| Sidebar | AppSwitcher → groups (`SidebarGroupContent`) → icons `size-4`**пустой** `SidebarFooter` |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
Запрещено в chrome: `SidebarRail`, `NavUser` footer, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`.
App Switcher ids: `vps-tracker` · `cfdm` · `evobgp`. Override: `VITE_APP_SWITCHER` JSON.
QuickActionGrid icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid `bg-primary` fills. Preview: [stats-12](https://reui.io/preview/base/stats-12).
## System monitor
`SystemMonitorPopover` in app-shell header next to `ModeToggle`. Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
`SystemMonitorPopover` in app-shell header next to `ModeToggle` (после AppsMenu). Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
## MCP workflow
@@ -63,7 +87,7 @@ Primitives: MCP `plugin-shadcn-shadcn` + `@evobgp/ui`.
## Spacing
- Main: `gap-4 md:gap-6`, `px-4 md:px-6`
- AppShell main: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` (shared chrome)
- No `space-y-*` / `space-x-*` — use `flex` + `gap-*`
- Max 1 primary CTA per screen
- Semantic tokens only (`variant="success"|"info"|"warning"`) — no raw `bg-emerald-*`
+1
View File
@@ -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)
+37
View File
@@ -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", "query must be an IP address or FQDN")
return
}
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
+79
View File
@@ -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)
}
}
+290
View File
@@ -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
}
+206
View File
@@ -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)
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH = "240px"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"