Compare commits

..
13 Commits
Author SHA1 Message Date
Denozordec b28ad88b22 feat(auth): enhance tenant resolution in JWT handling
CI / changes (push) Successful in 4s
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 58s
CI / commitlint (push) Skipped
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m15s
Updated the authentication logic to prioritize tenant resolution from JWT claims, specifically using `tenants.bgp` or `bgp_tenant_id` as the primary source. If these claims are absent, the system will fallback to the configured `EVOBGP_PORTAL_TENANT_ID`. This change improves the flexibility of tenant management in the authentication process. Additionally, updated related documentation to reflect these changes and added tests to ensure proper functionality.
2026-07-19 01:03:33 +07:00
Denozordec b871d62de6 feat(auth): add environment variables for portal SSO integration
Introduced new environment variables for the authentication portal in the production and microvps configurations. Updated the documentation to clarify the necessity of passing these variables to the evobgp-all service. This change enhances the authentication flow by enabling single sign-on (SSO) capabilities through JWT, ensuring a more secure and streamlined user experience.
2026-07-19 00:55:35 +07:00
DenozordecandCursor 4d83b8d673 feat(auth): integrate portal JWT for enhanced authentication and authorization
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
Added support for portal JWT authentication, enabling single sign-on (SSO) capabilities. Updated the application to handle JWT claims for user permissions and roles, enhancing security and access control. Refactored relevant components and API routes to accommodate the new authentication flow, ensuring a seamless user experience. Updated documentation to reflect the new authentication requirements and configurations.

Co-authored-by: Cursor <[email protected]>
2026-07-18 23:23:52 +07:00
DenozordecandCursor 2820cff988 refactor(web): streamline /network page with ReUI kit
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / web (push) Successful in 1m14s
CI / release (push) Successful in 4m23s
Сжать экран «Сеть»: KPI (stats-12) и вкладки Пиры/Спикеры через ResourcePage; убраны дубли charts и заглушка control-plane.

Co-authored-by: Cursor <[email protected]>
2026-07-18 01:12:42 +07:00
DenozordecandCursor 54b7ea3bd5 feat(lookup): resolve domain to IPs for membership check
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 1m6s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m6s
Для FQDN после проверки DOMAINS выполняется live DNS (A/AAAA), каждый IP проверяется по IP_RANGES и snapshots; в ответе resolved_ips / resolved_ip, UI KPI и OpenAPI обновлены.

Co-authored-by: Cursor <[email protected]>
2026-07-18 01:01:54 +07:00
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
Denozordec 1639ba40f3 refactor: update dashboard components to integrate QuickActionGrid and enhance settings
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 33s
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 56s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 4m14s
Removed the deprecated DashboardQuickLinkCard and replaced it with QuickActionGrid in the DashboardQuickLinks component for improved organization and user experience. Updated the AppearanceSettingsTab to include a toggle for displaying quick actions on the dashboard, enhancing user customization options. Adjusted the SystemMonitorPopover layout for better alignment and spacing. Updated documentation to reflect the new UI preferences for quick actions.
2026-07-17 20:34:25 +07:00
Denozordec 26a96bc824 refactor: update KPI components for improved layout and clarity
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 47s
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m21s
Revised the KpiStatGrid and KpiStatCardBody components to enhance the layout and presentation of KPI tiles. Updated the documentation to reflect the new horizontal compact hybrid design, ensuring better visual organization with the icon on the left and improved label and value alignment. Adjusted the skeleton loading state for a more cohesive user experience.
2026-07-17 18:37:00 +07:00
Denozordec 4fc5c96e63 refactor: enhance KPI components with variant support and improved layout
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 51s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m12s
Updated the KPI components to include a new KpiStatVariant type for better visual differentiation. Refactored the KpiStatCardBody and KpiStatGrid to utilize this variant, allowing for dynamic styling based on the KPI state. Improved the layout of the KpiStatGrid and its associated components for a more cohesive user experience, including adjustments to the Badge footer presentation.
2026-07-17 17:15:14 +07:00
Denozordec 3859983ae5 refactor: update UI components to utilize Frame for improved layout and consistency
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 1m7s
CI / go (push) Successful in 2m11s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m46s
Refactored multiple components, including PanelCard, SettingsCard, and DataGridShell, to replace Card with Frame for better organization and presentation. Enhanced the DataGridToolbar to support optional ReUI filters and improved the overall structure of the KPI stat grid. Updated .gitignore to include .env.local for local environment configurations, ensuring better management of environment variables.
2026-07-17 15:30:41 +07:00
98 changed files with 5751 additions and 945 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 43636,
"pid": 51184,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783570299043
"startedAt": 1784215907952
}
+6 -3
View File
@@ -22,9 +22,12 @@ alwaysApply: false
|------|------|------------|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, OpsDashboard, SettingsShell |
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
## Правила
@@ -45,8 +48,8 @@ alwaysApply: false
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
*Проверка:* https://ui.shadcn.com/docs/components/form
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
*Проверка:* https://ui.shadcn.com/docs/components/sonner
+1
View File
@@ -21,6 +21,7 @@ Thumbs.db
# Env / secrets (local only)
.env
.env.local
.env.*
!.env.example
!.env.*.example
+3 -1
View File
@@ -71,4 +71,6 @@ pnpm --filter @evobgp/web run build
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
+28
View File
@@ -0,0 +1,28 @@
# EvoBGP web (Vite) — переменные окружения.
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
# ---------------------------------------------------------------------------
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
# ---------------------------------------------------------------------------
# REUI_LICENSE_KEY=
# ---------------------------------------------------------------------------
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
# недоступен либо VITE_AUTH_ENABLED=false).
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
# ---------------------------------------------------------------------------
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
# ---------------------------------------------------------------------------
# Auth-portal SSO
# ---------------------------------------------------------------------------
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
# Пример:
# VITE_AUTH_ENABLED=true
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
#
# Backend опционально может отдавать GET /v1/auth/config
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
# VITE_AUTH_ENABLED=false
# VITE_AUTH_PORTAL_URL=http://localhost:5175
+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,16 +1,14 @@
"use client"
import { type ReactNode } from 'react'
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import { cn } from '@evobgp/ui/lib/utils'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@evobgp/ui/components/card"
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
interface SettingsCardProps {
title: string
@@ -22,6 +20,7 @@ interface SettingsCardProps {
footerClassName?: string
}
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
export function SettingsCard({
title,
description,
@@ -32,28 +31,23 @@ export function SettingsCard({
footerClassName,
}: SettingsCardProps) {
return (
<Card className={cn("gap-0 p-0", className)}>
{/* Header */}
<CardHeader className="gap-0 border-b px-5 py-3">
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader>
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
{/* Content */}
<CardContent className={cn("p-0", contentClassName)}>
{children}
</CardContent>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
{footer ? (
<CardFooter
className={cn(
"justify-end gap-2 border-t px-5 py-3",
footerClassName
)}
>
{footer}
</CardFooter>
) : null}
</Card>
{footer ? (
<FrameFooter
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
}
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
export interface CountedLineTab {
id: string
label: string
count?: number
}
interface CountedLineTabsProps {
tabs: CountedLineTab[]
value: string
onValueChange: (value: string) => void
className?: string
listClassName?: string
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
export function CountedLineTabs({
tabs,
value,
onValueChange,
className,
listClassName,
children,
}: CountedLineTabsProps) {
return (
<Tabs value={value} onValueChange={onValueChange} className={className}>
<TabsList variant="line" className={cn('gap-5', listClassName)}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tab.count}
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
)
}
@@ -122,6 +122,7 @@ function buildKpis({
iconClassName: 'text-destructive',
value: loading ? '—' : String(riskCount),
label: 'Риски',
variant: riskCount > 0 ? 'destructive' : 'default',
footer: (
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
{loading
@@ -1,69 +0,0 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import type { ReactNode } from 'react'
import { CardDotField } from '@/components/dashboard/card-dot-field'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { cn } from '@evobgp/ui/lib/utils'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
export type QuickLinkCardProps = {
icon: ReactNode
label: string
description: string
to: string
search?: Record<string, string>
iconClass: string
}
export function DashboardQuickLinkCard({
icon,
label,
description,
to,
search,
iconClass,
}: QuickLinkCardProps) {
return (
<Link
to={to}
search={search}
className="group block h-full rounded-[inherit] focus-visible:outline-none"
aria-label={`${label}: ${description}`}
>
<Card
size="sm"
className={cn(
'relative isolate h-full overflow-hidden transition-colors',
'hover:border-foreground/20',
'group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background',
)}
>
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
<CardContent className="relative z-10 flex h-full flex-col gap-7.5 p-5">
<Item
className={cn(
'border-background flex size-11 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5',
iconClass,
)}
>
<ItemMedia variant="icon" className="size-auto">
{icon}
</ItemMedia>
</Item>
<div className="mt-auto flex flex-col gap-3">
<span className="text-foreground block text-sm leading-tight font-medium">{label}</span>
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
<span className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 group-hover:underline">
Перейти
<ChevronRight
aria-hidden
className="size-2.5 shrink-0 transition-transform group-hover:translate-x-0.5"
/>
</span>
</div>
</CardContent>
</Card>
</Link>
)
}
@@ -1,78 +1,76 @@
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
import type { ReactNode } from 'react'
import { Gauge, Network, Play, Plus, Search, Share2, Tags } from 'lucide-react'
import { DashboardQuickLinkCard } from '@/components/dashboard/dashboard-quick-link-card'
import { PanelCard } from '@/components/panel-card'
import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit'
type QuickLink = {
icon: ReactNode
label: string
description: string
to: string
search?: Record<string, string>
iconClass: string
}
const LINKS: QuickLink[] = [
/** iconClassName: semantic text only (shared chrome with CFDM). @see https://reui.io/preview/base/stats-12 */
const ACTIONS: QuickActionItem[] = [
{
icon: <Plus aria-hidden />,
label: 'Создать модуль',
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',
iconClass: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
icon: <Plus aria-hidden />,
iconClassName: 'text-primary',
},
{
icon: <Tags aria-hidden />,
label: 'BGP-сообщества',
id: 'communities',
title: 'BGP-сообщества',
description: 'Справочник communities для политик экспорта.',
to: '/directories',
iconClass: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
icon: <Tags aria-hidden />,
iconClassName: 'text-info',
},
{
icon: <Network aria-hidden />,
label: 'Сеть',
id: 'network',
title: 'Сеть',
description: 'Обзор пиров, спикеров и live-сессий BGP.',
to: '/network',
search: { tab: 'overview' },
iconClass: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
icon: <Network aria-hidden />,
iconClassName: 'text-success',
},
{
icon: <Share2 aria-hidden />,
label: 'Добавить пира',
id: 'add-peer',
title: 'Добавить пира',
description: 'Настройка BGP-соседа и шаблонов сессии.',
to: '/network',
search: { tab: 'peers' },
iconClass: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
icon: <Share2 aria-hidden />,
iconClassName: 'text-warning',
},
{
icon: <Play aria-hidden />,
label: 'Деплой',
id: 'deploy',
title: 'Деплой',
description: 'Ревизии конфигурации и применение на нодах.',
to: '/operations',
search: { tab: 'revisions' },
iconClass: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
icon: <Play aria-hidden />,
iconClassName: 'text-muted-foreground',
},
{
icon: <Gauge aria-hidden />,
label: 'Мониторинг',
id: 'monitoring',
title: 'Мониторинг',
description: 'Состояние системы, BIRD и PostgreSQL.',
to: '/monitoring',
search: { tab: 'system' },
iconClass: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
icon: <Gauge aria-hidden />,
iconClassName: 'text-destructive',
},
]
export function DashboardQuickLinks() {
return (
<PanelCard
title="Быстрые действия"
<QuickActionGrid
actions={ACTIONS}
description="Частые переходы к настройке и деплою"
className="@container w-full"
contentClassName="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3"
>
{LINKS.map((link) => (
<DashboardQuickLinkCard key={link.label} {...link} />
))}
</PanelCard>
/>
)
}
+8 -3
View File
@@ -4,10 +4,15 @@ import type { Table } from '@tanstack/react-table'
import { cn } from '@evobgp/ui/lib/utils'
import { DataGridToolbar } from '@/components/data-grid-toolbar'
import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName, panelCardInsetClassName } from '@/components/panel-card'
import {
PanelCard,
panelCardContentFlushClassName,
panelCardFooterClassName,
} from '@/components/panel-card'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { FrameFooter } from '@/components/reui/frame'
import {
DATA_GRID_MESSAGES_RU,
DATA_GRID_PAGINATION_RU,
@@ -52,9 +57,9 @@ export function DataGridShell<TData extends object>({
<DataGridTable />
</DataGridContainer>
{showPagination ? (
<div className={cn(panelCardFooterClassName, panelCardInsetClassName)}>
<FrameFooter className={cn(panelCardFooterClassName)}>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
</div>
</FrameFooter>
) : null}
</DataGrid>
)
@@ -16,12 +16,13 @@ interface DataGridToolbarProps {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
/** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */
filters?: ReactNode
actions?: ReactNode
className?: string
}
/** Search row for data grids (ReUI c-input-group-37 pattern). */
/** Search + optional ReUI Filters row for Frame data grids. */
export function DataGridToolbar({
searchValue,
onSearchChange,
@@ -58,19 +59,19 @@ export function DataGridToolbar({
filters
) : (
<InputGroupButton
aria-label="Фильтры"
size="icon-xs"
variant="ghost"
size="xs"
className="pointer-events-none gap-1.5 opacity-0"
aria-hidden="true"
tabIndex={-1}
disabled
title="Передайте filters={<Filters … />} из @/components/reui/filters"
>
<ListFilterIcon className="size-3.5" />
<ListFilterIcon aria-hidden="true" />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</Field>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)
}
+14 -126
View File
@@ -1,126 +1,14 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
export type KpiStatItem = {
id?: string
icon?: ReactNode
iconClassName?: string
value: ReactNode
label: ReactNode
footer?: ReactNode
active?: boolean
onClick?: () => void
}
const DEFAULT_ICON_CLASS =
'text-muted-foreground [&_svg]:text-current'
function kpiStatGridClassName(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
function handleCardKeyDown(onClick: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onClick()
}
}
/** Single KPI tile (ReUI stats-12 / dashboard PRO pattern). */
export function KpiStatCard({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
const clickable = Boolean(item.onClick)
const panel = (
<FramePanel
className={cn(
'flex h-full flex-col items-start gap-6',
clickable && 'cursor-pointer transition-colors hover:bg-muted/30',
item.active && 'ring-1 ring-primary/30',
className,
)}
onClick={item.onClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onKeyDown={clickable && item.onClick ? (e) => handleCardKeyDown(item.onClick!, e) : undefined}
>
{item.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
item.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
<ItemMedia variant="icon" className="size-auto">
{item.icon}
</ItemMedia>
</Item>
) : null}
<div className="space-y-0.5">
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">{item.value}</div>
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
</div>
{item.footer ? <div className="mt-auto w-full">{item.footer}</div> : null}
</FramePanel>
)
if (embedded) {
return (
<Frame className="h-full ring-1 ring-foreground/10">
{panel}
</Frame>
)
}
return <Frame className="h-full">{panel}</Frame>
}
export function KpiStatGrid({
items,
className,
embedded = false,
'aria-label': ariaLabel,
}: {
items: KpiStatItem[]
className?: string
embedded?: boolean
'aria-label'?: string
}) {
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid gap-5', kpiStatGridClassName(items.length))}>
{items.map((item, index) => (
<KpiStatCard
key={item.id ?? (typeof item.label === 'string' ? item.label : `kpi-${index}`)}
item={item}
embedded={embedded}
/>
))}
</div>
</section>
)
}
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
if (item.id) return item.id
if (typeof item.label === 'string') return item.label
return `kpi-${index}`
}
/**
* Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable.
* @see https://reui.io/preview/base/stats-12
*/
export {
KpiStatGrid,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatVariant,
type OpsKpiCard,
} from '@/components/reui-kit/kpi-stat-grid'
+40 -21
View File
@@ -10,6 +10,7 @@ import {
KeyRound,
ServerCog,
Shield,
Search,
} from 'lucide-react'
import {
@@ -41,9 +42,13 @@ 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 { NavUser } from '@/components/layout/nav-user'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { ModeToggle } from '@/components/mode-toggle'
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
interface NavItem {
to: string
@@ -51,6 +56,8 @@ interface NavItem {
icon: ComponentType<{ className?: string }>
description?: string
search?: Record<string, string>
/** Explicit permission override; when omitted derived from `to`. */
permission?: string
}
interface NavGroup {
@@ -74,6 +81,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' },
],
@@ -99,6 +107,18 @@ const NAV_GROUPS: NavGroup[] = [
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
/** Nav visible for the current claim (or all items when portal auth is off). */
function useVisibleNavGroups(): NavGroup[] {
if (!isAuthEnabled()) return NAV_GROUPS
return NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => {
const perm = item.permission ?? permissionForPath(item.to)
return !perm || can(perm)
}),
})).filter((group) => group.items.length > 0)
}
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
)
@@ -116,8 +136,14 @@ 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 visibleGroups = useVisibleNavGroups()
const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
ALL_NAV_ITEMS[0]
@@ -134,29 +160,16 @@ 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) => (
{visibleGroups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
@@ -182,11 +195,13 @@ export function AppShell({ children }: { children: ReactNode }) {
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter />
<SidebarFooter>
<NavUser />
</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 +219,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,104 @@
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'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
/** 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 />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
) : (
<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}
+158
View File
@@ -0,0 +1,158 @@
import { Link } from '@tanstack/react-router'
import {
ChevronsUpDownIcon,
ExternalLinkIcon,
LogOutIcon,
SettingsIcon,
} from 'lucide-react'
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import {
authPortalUrl,
clearPortalToken,
getClaims,
isAuthEnabled,
redirectToPortalLogout,
resetPortalHandoff,
} from '@/lib/auth'
/**
* Sidebar footer account menu.
* Portal mode → shows JWT email + logout via auth-portal.
* Local mode → shows the API-key hint + clears the local token.
* @see https://reui.io/preview/base/app-shell-12
*/
function initials(source: string): string {
const base = source.trim()
if (!base) return '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const authOn = isAuthEnabled()
const claims = getClaims()
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
const email =
claims?.email?.trim() ||
(authOn ? '' : 'локальный API-токен')
const fallback = initials(name || email)
function handleSignOut() {
if (authOn) {
clearPortalToken()
resetPortalHandoff()
redirectToPortalLogout()
return
}
setApiToken(null)
if (typeof window !== 'undefined') {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
window.location.assign('/settings?tab=connection&reason=token-required')
}
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
>
<SettingsIcon aria-hidden />
Настройки UI
</DropdownMenuItem>
{authOn ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
>
<ExternalLinkIcon aria-hidden />
Открыть Auth Portal
</DropdownMenuItem>
) : null}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleSignOut}>
<LogOutIcon aria-hidden />
{authOn ? 'Выйти' : 'Сбросить токен'}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -181,7 +181,7 @@ export function SystemMonitorPopover() {
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-80 gap-0! space-y-0! p-0!">
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
@@ -0,0 +1,133 @@
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.resolved_ip
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
: 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,62 @@
import { Globe, Layers, ListChecks, Radar } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import type { LookupResponse } from '@/types/api'
/**
* Lookup summary KPI — stats-12 via KpiStatGrid.
* @see https://reui.io/preview/base/stats-12
*/
export function LookupSummaryKpi({ data }: { data: LookupResponse }) {
const entryCount = data.matches.filter((m) => m.layer === 'entry').length
const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length
const resolvedCount = data.resolved_ips?.length ?? 0
const items: KpiStatItem[] = [
{
id: 'matched',
label: 'Результат',
value: data.matched ? 'Найдено' : 'Не найдено',
hint: data.normalized,
icon: <Radar aria-hidden />,
iconClassName: data.matched
? 'bg-success text-success-foreground [&_svg]:text-success-foreground'
: 'bg-muted text-muted-foreground [&_svg]:text-muted-foreground',
variant: data.matched ? 'default' : 'warning',
},
{
id: 'entry',
label: 'Слой entry',
value: entryCount,
hint: 'сырые списки',
icon: <ListChecks aria-hidden />,
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
},
{
id: 'snapshot',
label: 'Слой snapshot',
value: snapshotCount,
hint: 'материализация',
icon: <Layers aria-hidden />,
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
},
]
if (data.query_kind === 'domain') {
items.push({
id: 'resolved',
label: 'DNS IP',
value: resolvedCount,
hint: resolvedCount > 0 ? data.resolved_ips?.slice(0, 3).join(', ') : 'нет A/AAAA',
icon: <Globe aria-hidden />,
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
})
}
return (
<KpiStatGrid
items={items}
aria-label={`Запрос: ${data.query_kind} · ${data.query}`}
/>
)
}
@@ -0,0 +1,115 @@
import { AlertTriangle, Network, ServerCog, Share2 } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatCardData } from '@/components/reui-kit'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { BirdStatus, PeerRow, SpeakerRow } from '@/types/api'
/**
* Network page KPI strip.
* @see https://reui.io/preview/base/stats-12
*/
export function NetworkKpi({
peers,
speakers,
bird,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
bird?: BirdStatus
loading?: boolean
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const birdHealthy = bird?.healthy
const birdSessions =
bird != null ? `${bird.bgp_established} / ${bird.bgp_sessions_total}` : '—'
const cards: KpiStatCardData[] = [
{
id: 'peers-established',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
label: 'Пиры Established',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${net.peersTotal} в каталоге`}
</Badge>
),
to: '/network',
search: { tab: 'peers' },
},
{
id: 'speakers-online',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-info',
label: 'Спикеры online',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
footer: (
<Badge
variant={
net.speakersOnline === net.speakersTotal && net.speakersTotal > 0
? 'success-light'
: 'warning-light'
}
size="sm"
>
{loading ? '…' : 'live'}
</Badge>
),
to: '/network',
search: { tab: 'speakers' },
},
{
id: 'mismatches',
icon: <AlertTriangle aria-hidden />,
iconClassName: net.peersMismatch > 0 ? 'text-warning' : 'text-muted-foreground',
label: 'Расхождения',
value: loading ? '—' : String(net.peersMismatch),
variant: net.peersMismatch > 0 ? 'warning' : 'default',
footer: (
<Badge variant={net.peersMismatch > 0 ? 'warning-light' : 'outline'} size="sm">
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'}
</Badge>
),
to: '/network',
search: { tab: 'peers' },
},
{
id: 'bird',
icon: <Network aria-hidden />,
iconClassName:
birdHealthy === false ? 'text-destructive' : 'text-primary',
label: 'BIRD',
value: loading ? '—' : birdSessions,
variant: birdHealthy === false ? 'destructive' : 'default',
footer: (
<Badge
variant={
birdHealthy === true
? 'success-light'
: birdHealthy === false
? 'destructive-light'
: 'outline'
}
size="sm"
>
{loading
? '…'
: birdHealthy === true
? 'в норме'
: birdHealthy === false
? 'проблема'
: 'н/д'}
</Badge>
),
},
]
return (
<section aria-label="Ключевые метрики сети">
<KpiStatGrid cards={cards} isLoading={loading} skeletonCount={4} />
</section>
)
}
@@ -1,17 +1,76 @@
import { useMemo, useState } from 'react'
import { Plus } from 'lucide-react'
import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import {
getPeerFilterFieldValue,
peerColumns,
peerTabFilter,
} from '@/components/network/network-peers-grid'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import type { PeerRow, SpeakerRow } from '@/types/api'
type PeerTab = 'all' | 'established' | 'pending' | 'disabled'
/**
* BGP peers list — ResourcePage (Frame + tabs + Filters + DataGrid).
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
const PEER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'established', label: 'Established' },
{ id: 'pending', label: 'Ожидание' },
{ id: 'disabled', label: 'Выключены' },
]
const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Established' },
{ value: 'Idle', label: 'Idle' },
{ value: 'Active', label: 'Active' },
{ value: 'Connect', label: 'Connect' },
{ value: 'OpenSent', label: 'OpenSent' },
{ value: 'OpenConfirm', label: 'OpenConfirm' },
]
function createDefaultPeerFilters(): Filter[] {
return [createFilter('name', 'contains', [''])]
}
const peerFilterFields: FilterFieldConfig[] = [
{
key: 'name',
label: 'Имя',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Поиск по имени…',
},
{
key: 'neighbor',
label: 'Сосед',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Адрес соседа…',
},
{
key: 'session_state',
label: 'Состояние',
icon: <ActivityIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[160px]',
options: SESSION_STATE_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
},
]
interface NetworkPeersCardProps {
items: PeerRow[]
@@ -22,24 +81,6 @@ interface NetworkPeersCardProps {
onRetry: () => void
}
function filterPeers(items: PeerRow[], tab: PeerTab): PeerRow[] {
if (tab === 'all') return items
if (tab === 'disabled') return items.filter((p) => p.enabled === false)
const enabled = items.filter((p) => p.enabled !== false)
if (tab === 'established') return enabled.filter((p) => p.session_state === 'Established')
return enabled.filter((p) => p.session_state !== 'Established')
}
function tabCounts(items: PeerRow[]) {
const enabled = items.filter((p) => p.enabled !== false)
return {
all: items.length,
established: enabled.filter((p) => p.session_state === 'Established').length,
pending: enabled.filter((p) => p.session_state !== 'Established').length,
disabled: items.length - enabled.length,
}
}
export function NetworkPeersCard({
items,
speakers,
@@ -49,48 +90,44 @@ export function NetworkPeersCard({
onRetry,
}: NetworkPeersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const [tab, setTab] = useState<PeerTab>('all')
const counts = useMemo(() => tabCounts(items), [items])
const filtered = useMemo(() => filterPeers(items, tab), [items, tab])
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
const addButton = useMemo(
() => (
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
),
[],
)
return (
<>
<DataGridCard
<ResourcePage
title="Пиры"
description="BGP-соседи и привязка к спикерам"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
}
>
<div className="px-5 pt-3">
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)} className="w-full">
<TabsList variant="line" className="w-full justify-start gap-6">
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
<TabsTrigger value="established">Established ({counts.established})</TabsTrigger>
<TabsTrigger value="pending">Ожидание ({counts.pending})</TabsTrigger>
<TabsTrigger value="disabled">Выключены ({counts.disabled})</TabsTrigger>
</TabsList>
</Tabs>
</div>
<QueryState
data={filtered}
isLoading={isLoading}
isError={isError}
error={error}
empty={filtered.length === 0}
emptyTitle="Нет пиров в выборке"
emptyDescription="Измените фильтр или добавьте BGP-соседа."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
tabs={PEER_TABS}
tabFilter={peerTabFilter}
filterFields={peerFilterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultPeerFilters())}
getFilterFieldValue={getPeerFilterFieldValue}
columns={peerColumns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onRetry={onRetry}
primaryAction={addButton}
emptyState={{
title: 'Нет пиров',
description: 'Добавьте первого BGP-соседа.',
action: addButton,
}}
/>
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
</>
@@ -1,90 +1,77 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { bgpSessionStateRu } from '@/lib/ui-labels'
import type { PeerRow } from '@/types/api'
export function NetworkPeersGrid({
items,
isLoading = false,
}: {
items: PeerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<PeerRow>[]>(
() => [
{
id: 'name',
accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Адрес соседа' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? '—'}
label={bgpSessionStateRu(row.original.session_state)}
/>
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
</div>
),
meta: { headerTitle: 'Состояние' },
},
],
[],
)
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
{
id: 'name',
accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Адрес соседа' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? ''}
label={bgpSessionStateRu(row.original.session_state)}
/>
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
</div>
),
meta: { headerTitle: 'Состояние' },
},
]
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
getRowId: (row) => row.id,
})
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
switch (field) {
case 'name':
return item.name ?? item.neighbor
case 'neighbor':
return item.neighbor
case 'session_state':
return item.session_state
default:
return undefined
}
}
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет пиров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск пиров…"
/>
)
export function peerTabFilter(item: PeerRow, tabId: string): boolean {
if (tabId === 'disabled') return item.enabled === false
const enabled = item.enabled !== false
if (tabId === 'established') return enabled && item.session_state === 'Established'
if (tabId === 'pending') return enabled && item.session_state !== 'Established'
return true
}
@@ -1,15 +1,65 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Plus, SearchIcon, TagIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import {
getSpeakerFilterFieldValue,
speakerColumns,
speakerTabFilter,
} from '@/components/network/network-speakers-grid'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import type { SpeakerRow } from '@/types/api'
/**
* BGP speakers list — ResourcePage (Frame + tabs + Filters + DataGrid).
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
const SPEAKER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'online', label: 'Online' },
{ id: 'offline', label: 'Offline' },
]
const ROLE_OPTIONS = [
{ value: 'primary', label: 'primary' },
{ value: 'secondary', label: 'secondary' },
{ value: 'speaker', label: 'speaker' },
]
function createDefaultSpeakerFilters(): Filter[] {
return [createFilter('endpoint', 'contains', [''])]
}
const speakerFilterFields: FilterFieldConfig[] = [
{
key: 'endpoint',
label: 'Конечная точка',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'endpoint…',
},
{
key: 'role',
label: 'Роль',
icon: <TagIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[140px]',
options: ROLE_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, ROLE_OPTIONS),
},
]
interface NetworkSpeakersCardProps {
items: SpeakerRow[]
isLoading: boolean
@@ -26,35 +76,44 @@ export function NetworkSpeakersCard({
onRetry,
}: NetworkSpeakersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
const addButton = useMemo(
() => (
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
),
[],
)
return (
<>
<DataGridCard
<ResourcePage
title="Спикеры"
description="BIRD-агенты на нодах tenant"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте первого BIRD-агента на ноде."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
tabs={SPEAKER_TABS}
tabFilter={speakerTabFilter}
filterFields={speakerFilterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
getFilterFieldValue={getSpeakerFilterFieldValue}
columns={speakerColumns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onRetry={onRetry}
primaryAction={addButton}
emptyState={{
title: 'Нет спикеров',
description: 'Добавьте первого BIRD-агента на ноде.',
action: addButton,
}}
/>
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
@@ -1,87 +1,78 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
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 { speakerOnlineLabel } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api'
export function NetworkSpeakersGrid({
items,
isLoading = false,
}: {
items: SpeakerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
() => [
{
accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
),
meta: { headerTitle: 'Конечная точка' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
return <Badge variant="outline" size="sm" radius="full"></Badge>
},
meta: { headerTitle: 'Агент' },
},
{
id: 'bgp',
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
return (
<span className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span>
)
},
meta: { headerTitle: 'BGP' },
},
],
[],
)
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
{
accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
),
meta: { headerTitle: 'Конечная точка' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) {
return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
}
if (live?.agent_ok === false) {
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
}
return (
<Badge variant="outline" size="sm" radius="full">
</Badge>
)
},
meta: { headerTitle: 'Агент' },
},
{
id: 'bgp',
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
return (
<span className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span>
)
},
meta: { headerTitle: 'BGP' },
},
]
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
getRowId: (row) => row.id,
})
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
switch (field) {
case 'endpoint':
return item.endpoint
case 'role':
return item.role
default:
return undefined
}
}
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет спикеров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск спикеров…"
/>
)
export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean {
if (tabId === 'online') return item.live?.agent_ok === true
if (tabId === 'offline') return item.live?.agent_ok !== true
return true
}
+53 -27
View File
@@ -1,21 +1,24 @@
import type { ReactNode } from 'react'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
/** REUI c-data-grid-19: card shell with default spacing and flush grid body. */
export const panelCardClassName = 'gap-0 has-data-[slot=card-footer]:pb-0'
/**
* Frame shell class constants (legacy Card names kept for DataGridShell / toolbar).
* @see https://reui.io/docs/components/base/frame
*/
export const panelCardClassName = 'w-full gap-0 p-0'
export const panelCardHeaderClassName = 'border-b'
export const panelCardContentFlushClassName = 'px-0'
/** Horizontal inset for toolbar / pagination rows (matches edgeCell px-5). */
/** Flush body inside FramePanel (toolbar / data-grid). */
export const panelCardContentFlushClassName = 'p-0'
/** Horizontal inset for toolbar / pagination rows. */
export const panelCardInsetClassName = 'px-5 py-3'
export const panelCardFooterClassName = 'border-t bg-transparent'
@@ -45,22 +48,45 @@ export function PanelCard({
size = 'default',
}: PanelCardProps) {
const hasHeader = Boolean(title || description || actions)
const spacing = size === 'sm' ? 'sm' : 'default'
return (
<Card size={size} className={cn(panelCardClassName, className)}>
{hasHeader ? (
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <CardDescription>{description}</CardDescription> : null}
{actions ? <CardAction>{actions}</CardAction> : null}
</CardHeader>
) : null}
{children != null && children !== false ? (
<CardContent className={contentClassName}>{children}</CardContent>
) : null}
{footer ? (
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
) : null}
</Card>
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader
className={cn(
panelCardHeaderClassName,
'flex-row items-start justify-between gap-3',
headerClassName,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
) : null}
{children != null && children !== false ? (
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
) : null}
{footer ? (
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,116 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
export interface DetailMetricCard {
id: string
icon: ReactNode
label: string
description: string
footer?: ReactNode
}
interface DetailPanelProps {
children: ReactNode
className?: string
}
function DetailPanelRoot({ children, className }: DetailPanelProps) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
interface DetailPanelHeaderProps {
title: string
description?: string
actions?: ReactNode
children?: ReactNode
}
function DetailPanelHeader({
title,
description,
actions,
children,
}: DetailPanelHeaderProps) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-px">
<FrameTitle>{title}</FrameTitle>
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
{children ? <FramePanel className="flex flex-col gap-4">{children}</FramePanel> : null}
</Frame>
)
}
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
return (
<div className="@container w-full">
<div className="grid gap-4 @2xl:grid-cols-3">
{cards.map((card) => (
<Frame key={card.id} spacing="sm">
<FrameHeader className="px-1! py-1!">
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
{card.icon}
<span className="text-foreground text-sm font-medium">
{card.label}
</span>
</div>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs leading-relaxed">
{card.description}
</p>
{card.footer}
</FramePanel>
</Frame>
))}
</div>
</div>
)
}
function DetailPanelSection({
title,
description,
children,
}: {
title?: string
description?: string
children: ReactNode
}) {
return (
<section className="flex flex-col gap-3">
{title ? (
<header className="px-1">
<h2 className="text-sm font-semibold">{title}</h2>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
) : null}
{children}
</section>
)
}
export const DetailPanel = Object.assign(DetailPanelRoot, {
Header: DetailPanelHeader,
Metrics: DetailPanelMetrics,
Section: DetailPanelSection,
})
@@ -0,0 +1,91 @@
import type { Filter } from '@/components/reui/filters'
export function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
return false
}
if (values.every((value) => value === null || value === undefined)) {
return false
}
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
return false
}
return true
})
}
export function applyFiltersToData<T>(
data: T[],
filters: Filter[],
getFieldValue: (item: T, field: string) => unknown,
): T[] {
const active = getActiveFilters(filters)
let result = [...data]
for (const filter of active) {
const { field, operator, values } = filter
result = result.filter((item) => {
const raw = getFieldValue(item, field)
const fieldValue = raw != null ? raw : ''
switch (operator) {
case 'is':
return values.includes(fieldValue)
case 'is_not':
return !values.includes(fieldValue)
case 'is_any_of':
return values.some((value) => fieldValue === value)
case 'is_not_any_of':
return !values.some((value) => fieldValue === value)
case 'contains': {
const tokens = values
.map((value) => String(value).trim())
.filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
)
}
case 'not_contains':
return !values.some((value) =>
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
)
case 'starts_with':
return values.some((value) =>
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
)
case 'ends_with':
return values.some((value) =>
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
)
case 'empty':
return fieldValue === '' || fieldValue == null
case 'not_empty':
return fieldValue !== '' && fieldValue != null
default:
return true
}
})
}
return result
}
export function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
return null
}
export function renderSingleSelectedLabel(
values: unknown[],
options: { value: string; label: string }[],
) {
const state = renderSelectedCount(values)
if (state) return state
const option = options.find((item) => item.value === values[0])
return option?.label ?? String(values[0])
}
+26
View File
@@ -0,0 +1,26 @@
export {
applyFiltersToData,
getActiveFilters,
renderSelectedCount,
renderSingleSelectedLabel,
} from './filter-utils'
export {
ResourcePage,
type ResourcePageProps,
type ResourcePageTab,
} from './resource-page'
export {
KpiStatGrid,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatCard as KpiStatCardType,
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
@@ -0,0 +1,334 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { Skeleton } from '@evobgp/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
/**
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
* @see https://reui.io/preview/base/stats-12
*/
export type KpiStatItem = {
id?: string
label: ReactNode
value: ReactNode
hint?: ReactNode
to?: string
search?: Record<string, unknown>
onSelect?: () => void
onClick?: () => void
selected?: boolean
active?: boolean
icon?: ReactNode
iconClassName?: string
variant?: KpiStatVariant
footer?: ReactNode
}
/** CFDM-compatible card shape (id required). */
export type KpiStatCardData = KpiStatItem & { id: string }
/** @deprecated Use KpiStatCardData */
export type OpsKpiCard = KpiStatCardData
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
export type KpiStatCard = KpiStatCardData
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: 'text-foreground',
warning: 'text-warning',
destructive: 'text-destructive',
}
function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
return item.onClick ?? item.onSelect
}
function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
{item.hint}
</Badge>
)
}
if (item.hint) return item.hint
return null
}
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
{item.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
item.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
<ItemMedia variant="icon" className="size-auto">
{item.icon}
</ItemMedia>
</Item>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
</div>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
</div>
</div>
)
}
function panelClassName(item: KpiStatItem, className?: string) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.to || onActivate)
const selected = isSelected(item)
return cn(
'relative isolate flex h-full flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
className,
)
}
/** Single KPI tile — used for embedded / standalone contexts. */
export function KpiStatCardTile({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
let panel: ReactNode
if (item.to) {
panel = (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
} else if (onActivate) {
panel = (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
} else {
panel = (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
if (embedded) {
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
}
return <Frame className="h-full">{panel}</Frame>
}
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
export function KpiStatCard({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn('grid gap-2', kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
</Frame>
)
}
interface KpiStatGridProps {
/** EvoBGP primary API */
items?: KpiStatItem[]
/** CFDM-compatible API */
cards?: KpiStatCardData[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
/** Wrap each tile in its own Frame (analytics panels). */
embedded?: boolean
'aria-label'?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.to) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/**
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
items,
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
embedded = false,
'aria-label': ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
const list = items ?? cards ?? []
if (list.length === 0 && (emptyMessage || emptyIcon)) {
return (
<Frame dense spacing="sm" className={cn('w-full', className)}>
<FramePanel className="flex items-center gap-3 p-4">
{emptyIcon}
{emptyMessage ? (
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
) : null}
</FramePanel>
</Frame>
)
}
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
))}
</div>
</section>
)
}
return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</Frame>
)
}
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
if (item.id) return item.id
if (typeof item.label === 'string') return item.label
return `kpi-${index}`
}
@@ -0,0 +1,86 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
export type OpsKpiCard = KpiStatCardData
interface OpsDashboardProps {
title?: string
description?: string
kpiCards: OpsKpiCard[]
charts: ReactNode
queue: ReactNode
queueTitle?: string
queueDescription?: string
isLoading?: boolean
}
const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
function OpsDashboardSkeleton() {
return (
<div className={rootClassName}>
<header className="px-1">
<Skeleton className="h-7 w-56" />
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
</header>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="grid gap-2 @3xl:grid-cols-2">
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
<Skeleton className="h-48 w-full rounded-xl" />
</div>
)
}
export function OpsDashboard({
title = 'Панель управления',
description = 'Обзор модулей, сети, задач и состояния плоскости управления',
kpiCards,
charts,
queue,
queueTitle = 'Требуют внимания',
queueDescription = 'Проблемы BGP, сбои задач и офлайн-спикеры',
isLoading = false,
}: OpsDashboardProps) {
if (isLoading) {
return <OpsDashboardSkeleton />
}
return (
<div className={rootClassName}>
<PageHeader title={title} description={description} />
<section aria-label="Ключевые метрики">
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
</section>
<section
aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
{charts}
</section>
<section aria-label={queueTitle}>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>{queueTitle}</FrameTitle>
<FrameDescription>{queueDescription}</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
</section>
</div>
)
}
@@ -0,0 +1,113 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { cn } from '@evobgp/ui/lib/utils'
export interface QuickActionItem {
id: string
title: string
description: string
to: string
search?: Record<string, unknown>
icon?: ReactNode
iconClassName?: string
}
interface QuickActionGridProps {
actions: QuickActionItem[]
title?: string
description?: string
className?: string
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
action.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
<ItemMedia variant="icon" className="size-auto">
{action.icon}
</ItemMedia>
</Item>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant="outline" size="sm" className="shrink-0">
Перейти
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{action.description}
</p>
</div>
</div>
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12
*/
export function QuickActionGrid({
actions,
title = 'Быстрые действия',
description,
className,
}: QuickActionGridProps) {
if (actions.length === 0) return null
return (
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
)}
<div className={cn('grid gap-2', kpiCols(actions.length))}>
{actions.map((action) => (
<FramePanel
key={action.id}
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
>
<Link
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={`${action.title}: ${action.description}`}
>
<QuickActionBody action={action} />
</Link>
</FramePanel>
))}
</div>
</Frame>
)
}
@@ -0,0 +1,370 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
Filters,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@evobgp/ui/components/button'
import { Separator } from '@evobgp/ui/components/separator'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from './filter-utils'
export interface ResourcePageTab {
id: string
label: string
count?: number
}
export interface ResourcePageProps<T extends object> {
title: string
description?: string
tabs?: ResourcePageTab[]
activeTab?: string
onTabChange?: (tabId: string) => void
tabFilter?: (item: T, tabId: string) => boolean
filterFields: FilterFieldConfig[]
filters: Filter[]
onFiltersChange: (filters: Filter[]) => void
onClearFilters?: () => void
getFilterFieldValue: (item: T, field: string) => unknown
columns: ColumnDef<T, unknown>[]
data: T[]
getRowId: (row: T) => string
isLoading?: boolean
isError?: boolean
error?: Error | null
onRetry?: () => void
primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode }
pageSize?: number
enableRowSelection?: boolean
selectionToolbar?: (ctx: {
selectedIds: string[]
selectedCount: number
clearSelection: () => void
}) => ReactNode
toolbarExtra?: ReactNode
hideHeader?: boolean
}
function ResourcePageSkeleton() {
return (
<Frame dense variant="default" spacing="sm" className="w-full">
<FrameHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-1 h-4 w-72" />
</FrameHeader>
<FramePanel className="p-0">
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-9 w-full max-w-md" />
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</FramePanel>
</Frame>
)
}
export function ResourcePage<T extends object>({
title,
description,
tabs,
activeTab: controlledTab,
onTabChange,
tabFilter,
filterFields,
filters,
onFiltersChange,
onClearFilters,
getFilterFieldValue,
columns,
data,
getRowId,
isLoading = false,
isError = false,
error = null,
onRetry,
primaryAction,
emptyState,
pageSize = 10,
enableRowSelection = false,
selectionToolbar,
toolbarExtra,
hideHeader = false,
}: ResourcePageProps<T>) {
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize,
})
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
)
}, [])
const filteredData = useMemo(() => {
let result = applyFiltersToData(data, filters, getFilterFieldValue)
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab))
}
return result
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {}
const base = applyFiltersToData(data, filters, getFilterFieldValue)
const counts: Record<string, number> = {}
for (const tab of tabs) {
counts[tab.id] =
tab.id === 'all'
? base.length
: base.filter((item) => tabFilter(item, tab.id)).length
}
return counts
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
)
const selectedCount = selectedIds.length
const clearSelection = useCallback(() => {
setRowSelection({})
}, [])
const table = useReactTable({
data: filteredData,
columns,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const handleTabChange = useCallback(
(value: string) => {
if (onTabChange) onTabChange(value)
else setInternalTab(value)
resetPagination()
},
[onTabChange, resetPagination],
)
const handleFiltersChange = useCallback(
(next: Filter[]) => {
onFiltersChange(next)
resetPagination()
},
[onFiltersChange, resetPagination],
)
const handleClear = useCallback(() => {
onClearFilters?.()
resetPagination()
}, [onClearFilters, resetPagination])
const countedTabs = useMemo(
() =>
(tabs ?? []).map((tab) => ({
id: tab.id,
label: tab.label,
count: tabCounts[tab.id] ?? tab.count ?? 0,
})),
[tabs, tabCounts],
)
if (isLoading) {
return <ResourcePageSkeleton />
}
if (isError) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
{onRetry ? (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
if (data.length === 0 && emptyState) {
return (
<EmptyState
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
/>
)
}
const emptyMessage = 'Нет записей по выбранным фильтрам.'
return (
<div className="w-full">
{selectionToolbar && selectedCount > 0
? selectionToolbar({
selectedIds,
selectedCount,
clearSelection,
})
: null}
<DataGrid
table={table}
recordCount={filteredData.length}
emptyMessage={emptyMessage}
tableLayout={{ dense: true }}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>{description}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="tabular-nums">
{filteredData.length} записей
</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} выбрано</span>
</>
) : null}
</FrameDescription>
) : null}
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
) : null}
</FrameHeader>
) : null}
<FramePanel className="p-0 shadow-none!">
{countedTabs.length > 0 ? (
<>
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={countedTabs}
value={activeTab}
onValueChange={handleTabChange}
/>
</div>
<Separator />
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{onClearFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</div>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
</div>
)
}
@@ -0,0 +1,103 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
import { cn } from '@evobgp/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Separator } from '@evobgp/ui/components/separator'
export interface SettingsTabConfig {
id: string
to: string
label: string
icon?: ReactNode
}
/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */
const DEFAULT_TABS: SettingsTabConfig[] = [
{
id: 'ui',
to: '/settings',
label: 'Настройки UI',
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
},
{
id: 'tenant',
to: '/tenant-settings',
label: 'Настройки BIRD',
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
}
export function SettingsShell({
title = 'Настройки',
description = 'Подключение UI и параметры плоскости управления',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<div className="flex flex-col gap-3">
<PageHeader title={title} description={description} />
<Separator />
</div>
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
>
{tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
</div>
</PageShell>
)
}
+3 -1
View File
@@ -32,15 +32,17 @@ function hintToFooter(hint: ReactNode) {
}
function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
const variant = item.variant ?? 'default'
const footer =
item.badge ?? (item.hint ? hintToFooter(item.hint) : undefined)
return {
id: typeof item.label === 'string' ? item.label : `section-${index}`,
icon: item.icon,
iconClassName: VARIANT_ICON_CLASS[item.variant ?? 'default'],
iconClassName: VARIANT_ICON_CLASS[variant],
value: item.value,
label: item.label,
variant,
footer,
active: item.active,
onClick: item.onClick,
@@ -1,5 +1,7 @@
import { Moon, Sun, SunMoon } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
@@ -9,6 +11,9 @@ import {
ToggleGroup,
ToggleGroupItem,
} from '@evobgp/ui/components/toggle-group'
import { Switch } from '@evobgp/ui/components/switch'
import { apiMutate } from '@/lib/api-client'
import { settingsKeys, settingsQueryOptions } from '@/queries/settings'
const THEME_OPTIONS = [
{ value: 'light', label: 'Светлая', icon: Sun },
@@ -16,8 +21,27 @@ const THEME_OPTIONS = [
{ value: 'system', label: 'Система', icon: SunMoon },
] as const
function parseShowQuickActions(value: unknown): boolean {
if (value === false || value === 0 || value === 'false' || value === '0') return false
return true
}
export function AppearanceSettingsTab() {
const { theme, setTheme } = useTheme()
const qc = useQueryClient()
const settingsQ = useQuery(settingsQueryOptions())
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
const patchMut = useMutation({
mutationFn: (payload: Record<string, boolean>) =>
apiMutate('/v1/settings', 'PATCH', payload),
onSuccess: () => {
toast.success('Настройки интерфейса сохранены')
void qc.invalidateQueries({ queryKey: settingsKeys.all })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
return (
<div className="space-y-6">
@@ -63,6 +87,31 @@ export function AppearanceSettingsTab() {
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Дашборд"
description="Блоки на экране «Обзор»"
>
<SettingsFieldGroup
legend="Быстрые действия"
description="Показывать KPI-like плитки быстрых переходов под метриками."
>
<SettingRow
title="Быстрые действия"
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
last
>
<Switch
checked={showQuickActions}
disabled={settingsQ.isLoading || patchMut.isPending}
onCheckedChange={(checked) =>
patchMut.mutate({ ui_show_quick_actions: checked })
}
aria-label="Показывать быстрые действия"
/>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
DEFAULT_APP_SWITCHER_CONFIG,
getAppUrl as getAppUrlFromConfig,
type AppSwitcherConfig,
} from '@/lib/app-switcher-config'
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
import { getClaims, isAuthEnabled } from '@/lib/auth'
/**
* Portal-first switcher: tries `/api/v1/app-switcher` and filters entries by
* the current JWT `apps` claim; falls back to `VITE_APP_SWITCHER`/defaults
* when auth-portal is disabled or unavailable.
*/
export function useAppSwitcherConfig(): {
config: AppSwitcherConfig
isLoading: boolean
} {
const authOn = isAuthEnabled()
const { data, isLoading } = useQuery({
...appSwitcherQueryOptions(),
enabled: authOn,
})
const claims = getClaims()
const config = useMemo<AppSwitcherConfig>(() => {
const raw = data ?? DEFAULT_APP_SWITCHER_CONFIG
if (!authOn) return raw
const allowed = claims?.apps
if (!allowed?.length) return raw
const set = new Set(allowed)
const apps = raw.apps.filter((a) => set.has(a.id))
if (!apps.length) return raw
return { ...raw, apps }
}, [data, authOn, claims?.apps])
return { config, isLoading: authOn && isLoading }
}
export function useAppUrl(appId: string): string | undefined {
const { config } = useAppSwitcherConfig()
return getAppUrlFromConfig(appId, config ?? DEFAULT_APP_SWITCHER_CONFIG)
}
+11
View File
@@ -30,8 +30,19 @@ export function normalizeApiToken(raw: string): string {
return t
}
/** Portal JWT storage key mirrored from `@/lib/auth`. Kept local to avoid a
* cycle when `auth` starts pulling from `api-client` for the config endpoint. */
const PORTAL_TOKEN_STORAGE_KEY = 'evobgp_portal_token'
/**
* Bearer selection: portal JWT wins over the legacy API-key. When auth-portal
* is disabled or hasn't issued a token yet we fall back to the local API-key
* (`evobgp_api_token`) so curl-style tooling keeps working.
*/
function getToken(): string | null {
if (typeof window === 'undefined') return null
const portal = window.localStorage.getItem(PORTAL_TOKEN_STORAGE_KEY)
if (portal && portal.trim()) return portal.trim()
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
if (!raw) return null
const normalized = normalizeApiToken(raw)
+104
View File
@@ -0,0 +1,104 @@
import {
ChartBarIcon,
CloudIcon,
GlobeIcon,
LayoutDashboardIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { z } from 'zod'
import { CURRENT_APP_ID } from '@/lib/auth'
export { CURRENT_APP_ID }
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',
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: 'bgp',
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]!
}
+304
View File
@@ -0,0 +1,304 @@
/**
* Portal JWT SSO integration for EvoBGP UI.
*
* Two independent auth channels:
* - Portal JWT (this module) — SSO from auth-portal, used to gate UI + Bearer
* to EvoBGP API when the API accepts portal-issued tokens.
* - Local API key (see `@/lib/api-client`) — legacy `evobgp_api_token`
* in localStorage; used when auth-portal is disabled or as a fallback.
*
* `VITE_AUTH_ENABLED=false` → keep the API-key gate.
* `VITE_AUTH_ENABLED=true` → require portal JWT; API-key kept only for tools
* (curl/dev) and as backup.
*/
const TOKEN_KEY = 'evobgp_portal_token'
const HANDOFF_KEY = 'evobgp_portal_401_handoff'
const HANDOFF_AT_KEY = 'evobgp_portal_handoff_at'
/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */
const HANDOFF_COOLDOWN_MS = 12_000
/** EvoBGP is `bgp` in the auth-portal registry (see APP_IDS). */
export const CURRENT_APP_ID = 'bgp'
export type AccessClaims = {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss?: string
exp?: number
}
export type RuntimeAuthConfig = {
required: boolean
portalUrl: string
}
let runtimeConfig: RuntimeAuthConfig | null = null
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
function viteAuthEnabled(): boolean {
return (
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
import.meta.env.VITE_AUTH_ENABLED === '1'
)
}
function vitePortalUrl(): string {
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
/\/$/,
'',
)
}
/**
* Load auth mode from EvoBGP API (Docker-friendly). Falls back to VITE_* flags
* when the endpoint isn't implemented (404) or the API is unreachable.
*
* Note: EvoBGP uses `/v1/...` (not `/api/v1/...`).
*/
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
if (runtimeConfig) return runtimeConfig
if (runtimeConfigPromise) return runtimeConfigPromise
runtimeConfigPromise = (async () => {
try {
const res = await fetch('/v1/auth/config', {
headers: { Accept: 'application/json' },
})
if (res.ok) {
const data = (await res.json()) as {
required?: boolean
portal_url?: string
}
runtimeConfig = {
required: Boolean(data.required) || viteAuthEnabled(),
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
}
return runtimeConfig
}
} catch {
/* ignore — fall through to vite defaults */
}
runtimeConfig = {
required: viteAuthEnabled(),
portalUrl: vitePortalUrl(),
}
return runtimeConfig
})().finally(() => {
runtimeConfigPromise = null
})
return runtimeConfigPromise
}
export function getAuthConfigSync(): RuntimeAuthConfig | null {
return runtimeConfig
}
export function getPortalToken(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(TOKEN_KEY)
}
export function setPortalToken(token: string): void {
if (typeof window === 'undefined') return
window.localStorage.setItem(TOKEN_KEY, token)
}
export function clearPortalToken(): void {
if (typeof window === 'undefined') return
window.localStorage.removeItem(TOKEN_KEY)
}
export function isAuthEnabled(): boolean {
if (runtimeConfig) return runtimeConfig.required
return viteAuthEnabled()
}
export function authPortalUrl(): string {
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
return vitePortalUrl()
}
export function isPortalHandoffCoolingDown(): boolean {
if (typeof window === 'undefined') return false
const raw = window.sessionStorage.getItem(HANDOFF_AT_KEY)
if (!raw) return false
const at = Number(raw)
if (!Number.isFinite(at)) return false
return Date.now() - at < HANDOFF_COOLDOWN_MS
}
export function markPortalHandoff(): void {
if (typeof window === 'undefined') return
window.sessionStorage.setItem(HANDOFF_KEY, '1')
window.sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
}
export function clearPortalHandoffFlag(): void {
if (typeof window === 'undefined') return
window.sessionStorage.removeItem(HANDOFF_KEY)
}
/** Clear cooldown too — use on intentional logout so next login is allowed. */
export function resetPortalHandoff(): void {
if (typeof window === 'undefined') return
window.sessionStorage.removeItem(HANDOFF_KEY)
window.sessionStorage.removeItem(HANDOFF_AT_KEY)
}
export function hasPortalHandoffFlag(): boolean {
if (typeof window === 'undefined') return false
return window.sessionStorage.getItem(HANDOFF_KEY) === '1'
}
/**
* Redirect to auth-portal SSO. Returns false if cooldown blocks the handoff
* (clears local token) — prevents infinite SSO when API rejects JWT.
*/
export function redirectToPortalLogin(returnTo?: string): boolean {
if (typeof window === 'undefined') return false
if (isPortalHandoffCoolingDown()) {
clearPortalToken()
return false
}
markPortalHandoff()
const callback = returnTo ?? `${window.location.origin}/auth/callback`
const url = new URL(authPortalUrl())
url.searchParams.set('return_to', callback)
window.location.assign(url.toString())
return true
}
/** End portal SSO session (refresh cookie + portal token). */
export function redirectToPortalLogout(): void {
clearPortalToken()
resetPortalHandoff()
if (typeof window === 'undefined') return
window.location.assign(`${authPortalUrl()}/logout`)
}
export function parseHashToken(hash: string): {
accessToken: string | null
expiresAt: string | null
} {
const raw = hash.startsWith('#') ? hash.slice(1) : hash
const params = new URLSearchParams(raw)
return {
accessToken: params.get('access_token'),
expiresAt: params.get('expires_at'),
}
}
export function decodeClaims(token: string): AccessClaims | null {
try {
const parts = token.split('.')
if (parts.length < 2) return null
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
const payload = JSON.parse(json) as Record<string, unknown>
return {
sub: String(payload.sub ?? ''),
email: String(payload.email ?? ''),
name: String(payload.name ?? ''),
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
permissions: Array.isArray(payload.permissions)
? payload.permissions.map(String)
: [],
is_admin: Boolean(payload.is_admin),
iss: payload.iss ? String(payload.iss) : undefined,
exp: typeof payload.exp === 'number' ? payload.exp : undefined,
}
} catch {
return null
}
}
export function getClaims(): AccessClaims | null {
const token = getPortalToken()
if (!token) return null
const claims = decodeClaims(token)
if (!claims) return null
if (claims.exp && claims.exp * 1000 < Date.now()) {
clearPortalToken()
return null
}
return claims
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(':')
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === 'read') {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === 'write') {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
/** Access-check: pass when portal auth is disabled or claim grants required. */
export function can(required: string): boolean {
if (!isAuthEnabled()) return true
const claims = getClaims()
if (!claims) return false
if (!claims.apps.includes(CURRENT_APP_ID)) return false
if (claims.is_admin) return true
return hasPermission(claims.permissions, required)
}
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
export function permissionForPath(pathname: string): string | null {
if (pathname === '/' || pathname.startsWith('/dashboard')) {
return 'bgp:dashboard:read'
}
if (pathname.startsWith('/modules')) return 'bgp:modules:read'
if (pathname.startsWith('/lookup')) return 'bgp:lookup:read'
if (pathname.startsWith('/network')) return 'bgp:network:read'
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
if (pathname.startsWith('/firewall')) return 'bgp:firewall:read'
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
if (pathname.startsWith('/access')) return 'bgp:access:admin'
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
return null
}
const FALLBACK_PATH = '/dashboard'
/** First path in the sidebar the current user may open. */
export function firstAllowedPath(): string {
const candidates: readonly string[] = [
'/dashboard',
'/modules',
'/lookup',
'/network',
'/directories',
'/operations',
'/firewall',
'/schedule',
'/monitoring',
'/access',
'/tenant-settings',
'/settings',
]
for (const path of candidates) {
const perm = permissionForPath(path)
if (!perm || can(perm)) return path
}
return FALLBACK_PATH
}
+8 -5
View File
@@ -1,13 +1,16 @@
/**
* EvoBGP UI surface contract (ReUI Pro).
*
* KPI tiles: **stats-12** pattern via `KpiStatGrid` / `SectionCards` (Frame + icon tile + badge).
* Panels and data grids: shadcn Card via `PanelCard` / `DataGridCard`.
* Ops / list / dashboard / detail / settings: **Frame** only.
* KPI tiles: **stats-12** via `KpiStatGrid` / `reui-kit/KpiStatGrid`.
* Lists: Frame + DataGrid (`ResourcePage` / `DataGridSection`), not Card shell.
*
* @see https://reui.io/blocks — stats-12, dashboard-1
* @see `.agents/skills/shadcn-react/SKILL.md`
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see docs/ui-design-contract.md
*/
export const UI_SURFACE = 'card' as const
export const UI_SURFACE = 'frame' as const
/** Grid for ReUI stats-12 KPI rows (36 tiles). */
export const kpiStatGridClassName = '@container w-full'
+32
View File
@@ -0,0 +1,32 @@
import { queryOptions } from '@tanstack/react-query'
import { ensureAuthConfig } from '@/lib/auth'
import {
DEFAULT_APP_SWITCHER_CONFIG,
parseAppSwitcherConfig,
type AppSwitcherConfig,
} from '@/lib/app-switcher-config'
export const appSwitcherQueryKey = ['app-switcher', 'portal'] as const
/** Portal contract shape: `{ menuLabel, apps: [{ id, name, url, icon, enabled }] }`. */
async function fetchPortalAppSwitcher(): Promise<AppSwitcherConfig> {
const { portalUrl } = await ensureAuthConfig()
const base = portalUrl.replace(/\/$/, '')
const res = await fetch(`${base}/api/v1/app-switcher`, {
headers: { Accept: 'application/json' },
})
if (!res.ok) throw new Error(`app-switcher ${res.status}`)
const raw = (await res.json()) as unknown
return parseAppSwitcherConfig(JSON.stringify(raw))
}
export function appSwitcherQueryOptions() {
return queryOptions({
queryKey: appSwitcherQueryKey,
queryFn: fetchPortalAppSwitcher,
staleTime: 60_000,
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
retry: 1,
})
}
+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,
})
}
+58 -4
View File
@@ -1,15 +1,69 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import {
can,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getPortalToken,
permissionForPath,
redirectToPortalLogin,
} from '@/lib/auth'
/**
* Two-mode gate:
* - VITE_AUTH_ENABLED / API `/v1/auth/config { required: true }`
* → require auth-portal JWT (SSO) + section permission via `can()`.
* - Off → keep legacy `evobgp_api_token` gate (redirect to /settings if empty).
*/
export const Route = createFileRoute('/_auth')({
beforeLoad: ({ location }) => {
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
beforeLoad: async ({ location }) => {
const cfg = await ensureAuthConfig()
if (cfg.required) {
const token = getPortalToken()
const claims = getClaims()
if (!token || !claims) {
const ok = redirectToPortalLogin(
`${window.location.origin}/auth/callback`,
)
if (!ok) {
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
await new Promise(() => {})
return
}
if (!claims.apps.includes('bgp')) {
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
const perm = permissionForPath(location.pathname)
if (perm && !can(perm)) {
const fallback = firstAllowedPath()
if (fallback !== location.pathname) {
throw redirect({ to: fallback as '/dashboard' })
}
}
return
}
// Settings available without token — first-run onboarding (incl. `dev`).
if (location.pathname === '/settings') return
const raw =
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
typeof window !== 'undefined'
? window.localStorage.getItem(TOKEN_STORAGE_KEY)
: null
if (!raw || !normalizeApiToken(raw)) {
throw redirect({ to: '/settings', search: { tab: 'connection', reason: 'token-required' } })
throw redirect({
to: '/settings',
search: { tab: 'connection', reason: 'token-required' },
})
}
},
component: AuthLayout,
+11 -3
View File
@@ -1,5 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import { useQueries, useQuery } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useState } from 'react'
@@ -27,13 +27,21 @@ import {
overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions,
} from '@/queries/overview'
import { settingsQueryOptions } from '@/queries/settings'
export const Route = createFileRoute('/_auth/dashboard')({
component: DashboardComponent,
})
function parseShowQuickActions(value: unknown): boolean {
if (value === false || value === 0 || value === 'false' || value === '0') return false
return true
}
function DashboardComponent() {
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const settingsQ = useQuery(settingsQueryOptions())
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
const results = useQueries({
queries: [
@@ -86,8 +94,6 @@ function DashboardComponent() {
}
/>
<DashboardQuickLinks />
{initialLoading ? (
<AnalyticsDashboardSkeleton />
) : (
@@ -99,6 +105,8 @@ function DashboardComponent() {
jobs={jobs}
/>
{showQuickActions ? <DashboardQuickLinks /> : null}
<div className={dashboardMainSidebarClassName}>
<div className="xl:col-span-8">
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
+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>
)
}
+44 -101
View File
@@ -1,28 +1,38 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@evobgp/ui/components/button'
import { PanelCard } from '@/components/panel-card'
import { TabsContent } from '@evobgp/ui/components/tabs'
import { RefreshCw } from 'lucide-react'
import {
DashboardNetworkCapacityCard,
NetworkOverviewAnalyticsCard,
} from '@/components/analytics'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { NetworkKpi } from '@/components/network/network-kpi'
import { NetworkPeersCard } from '@/components/network/network-peers-card'
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
import { overviewJobsQueryOptions } from '@/queries/overview'
import {
networkBirdQueryOptions,
networkPeersQueryOptions,
networkSpeakersQueryOptions,
} from '@/queries/network'
type NetworkTab = 'peers' | 'speakers'
function parseNetworkTab(value: unknown): NetworkTab {
if (value === 'speakers') return 'speakers'
// legacy: overview | control-plane → peers
return 'peers'
}
/**
* Network ops page — KPI (stats-12) + peers/speakers ResourcePage lists.
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/preview/base/empty-state-12
*/
export const Route = createFileRoute('/_auth/network')({
component: NetworkComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'peers' || search.tab === 'speakers' || search.tab === 'control-plane'
? search.tab
: 'overview') as 'overview' | 'peers' | 'speakers' | 'control-plane',
tab: parseNetworkTab(search.tab),
}),
})
@@ -32,12 +42,10 @@ function NetworkComponent() {
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery(overviewJobsQueryOptions())
const refreshing = peersQ.isFetching || speakersQ.isFetching
const refreshing = peersQ.isFetching || speakersQ.isFetching || birdQ.isFetching
const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
function refetchAll() {
@@ -47,121 +55,56 @@ function NetworkComponent() {
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Сеть"
description="BGP-пиры, спикеры и live-метрики нод"
description="BGP-пиры, спикеры и статус BIRD"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
Обновить
</Button>
}
/>
<BadgeTabs
<NetworkKpi
peers={peers}
speakers={speakers}
bird={birdQ.data}
loading={overviewLoading || birdQ.isLoading}
/>
<CountedLineTabs
value={search.tab}
onValueChange={(tab) =>
navigate({
search: {
tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
},
})
navigate({ search: { tab: tab as NetworkTab } })
}
items={[
{ value: 'overview', label: 'Обзор' },
{ value: 'peers', label: 'Пиры', count: peers.length },
{ value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
{ value: 'control-plane', label: 'Плоскость управления' },
tabs={[
{ id: 'peers', label: 'Пиры', count: peers.length },
{ id: 'speakers', label: 'Спикеры', count: speakers.length },
]}
>
<TabsContent value="overview" className="mt-0">
<div className="grid gap-4 lg:grid-cols-2">
<NetworkOverviewAnalyticsCard
peers={peers}
speakers={speakers}
loading={overviewLoading}
/>
<DashboardNetworkCapacityCard
peers={peers}
speakers={speakers}
jobs={jobs}
loading={overviewLoading}
/>
</div>
<PanelCard
className="mt-4"
title="BIRD (control plane)"
description="Статус birdc на хосте API"
contentClassName="py-4"
>
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<TableSkeleton rows={3} cols={2} />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="peers" className="mt-0">
<TabsContent value="peers" className="mt-4">
<NetworkPeersCard
items={peers}
speakers={speakers}
isLoading={peersQ.isLoading}
isError={peersQ.isError}
error={peersQ.error}
onRetry={() => peersQ.refetch()}
onRetry={() => void peersQ.refetch()}
/>
</TabsContent>
<TabsContent value="speakers" className="mt-0">
<TabsContent value="speakers" className="mt-4">
<NetworkSpeakersCard
items={speakers}
isLoading={speakersQ.isLoading}
isError={speakersQ.isError}
error={speakersQ.error}
onRetry={() => speakersQ.refetch()}
onRetry={() => void speakersQ.refetch()}
/>
</TabsContent>
<TabsContent value="control-plane" className="mt-0">
<PanelCard
title="Настройки Control Plane (BIRD)"
description="Конфигурация tenant-level — в разделе «Настройки BIRD»"
contentClassName="py-4 text-sm text-muted-foreground"
>
См. раздел «Настройки BIRD».
</PanelCard>
</TabsContent>
</BadgeTabs>
</div>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
return (
<div className="flex flex-col gap-2 text-sm">
<Field
label="Состояние"
value={bird.healthy === true ? 'В норме' : bird.healthy === false ? 'Проблема' : 'Н/Д'}
/>
<Field label="Сессий BGP" value={`${bird.bgp_established} / ${bird.bgp_sessions_total}`} />
{bird.message ? <p className="text-xs text-muted-foreground">{bird.message}</p> : null}
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
</CountedLineTabs>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import {
authPortalUrl,
clearPortalHandoffFlag,
clearPortalToken,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getPortalToken,
parseHashToken,
redirectToPortalLogin,
setPortalToken,
} from '@/lib/auth'
/**
* SSO callback — reads `#access_token=…&expires_at=…` returned by auth-portal,
* stores the JWT, and drops the user on the first allowed page.
*
* If the hash is empty (direct visit / already logged in) the route re-runs the
* portal handshake; the cool-down guard prevents redirect storms.
*/
export const Route = createFileRoute('/auth/callback')({
validateSearch: (search: Record<string, unknown>) => ({
error: typeof search.error === 'string' ? search.error : undefined,
}),
beforeLoad: async ({ search }) => {
await ensureAuthConfig()
if (search.error === 'sso_loop') return
if (typeof window === 'undefined') return
const { accessToken } = parseHashToken(window.location.hash)
if (accessToken) {
setPortalToken(accessToken)
clearPortalHandoffFlag()
const claims = getClaims()
if (!claims) {
clearPortalToken()
window.location.assign(authPortalUrl())
await new Promise(() => {})
return
}
throw redirect({ to: firstAllowedPath() as '/dashboard' })
}
if (getPortalToken() && getClaims()) {
clearPortalHandoffFlag()
throw redirect({ to: firstAllowedPath() as '/dashboard' })
}
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
if (!ok) {
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
await new Promise(() => {})
},
component: AuthCallbackPage,
})
function AuthCallbackPage() {
const { error } = Route.useSearch()
if (error === 'sso_loop') {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
<h1 className="text-lg font-semibold">Сессия не принята</h1>
<p className="text-muted-foreground max-w-md text-sm">
Повторный вход через auth-portal остановлен (защита от цикла редиректов).
Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.
Войдите заново на portal, затем откройте EvoBGP.
</p>
<a className="text-primary text-sm underline" href={authPortalUrl()}>
Открыть Auth Portal
</a>
</div>
)
}
return null
}
+31
View File
@@ -151,6 +151,37 @@ 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
resolved_ip?: string
}
export type LookupResponse = {
query: string
query_kind: LookupQueryKind
normalized: string
matched: boolean
match_count: number
matches: LookupMatch[]
resolved_ips?: string[]
}
// ---- Peers ----
export type PeerSessionOnSpeaker = {
speaker_id: string
+13
View File
@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_SWITCHER?: string
/** '1' | 'true' → require auth-portal JWT; иначе — локальный API-токен. */
readonly VITE_AUTH_ENABLED?: string
/** URL auth-portal (SSO). Пример: http://192.168.100.67:5175 или https://auth.shnt.top. */
readonly VITE_AUTH_PORTAL_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
File diff suppressed because one or more lines are too long
+30
View File
@@ -37,6 +37,11 @@ func main() {
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
RuntimeLogsPolicyTenant: cfg.RuntimeLogsPolicyTenant,
JWTSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_JWT_SECRET"), os.Getenv("AUTH_JWT_SECRET")),
AuthIssuer: firstNonEmpty(os.Getenv("EVOBGP_AUTH_ISSUER"), os.Getenv("AUTH_ISSUER")),
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
}
srv, err := httpapi.New(opts)
if err != nil {
@@ -86,6 +91,31 @@ func main() {
log.Printf("%s stopped", platform.ServiceName("evobgp-all"))
}
func firstNonEmpty(candidates ...string) string {
for _, c := range candidates {
if v := strings.TrimSpace(c); v != "" {
return v
}
}
return ""
}
func boolFromEnv(keys ...string) bool {
for _, k := range keys {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
continue
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return false
}
func startBirdMetricsPoller(ctx context.Context) {
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
if sock == "" {
+30
View File
@@ -32,6 +32,11 @@ func main() {
SeedDemo: seedDemo,
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
JWTSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_JWT_SECRET"), os.Getenv("AUTH_JWT_SECRET")),
AuthIssuer: firstNonEmpty(os.Getenv("EVOBGP_AUTH_ISSUER"), os.Getenv("AUTH_ISSUER")),
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
}
srv, err := httpapi.New(opts)
if err != nil {
@@ -78,6 +83,31 @@ func main() {
log.Printf("%s stopped", platform.ServiceName("evobgp-api"))
}
func firstNonEmpty(candidates ...string) string {
for _, c := range candidates {
if v := strings.TrimSpace(c); v != "" {
return v
}
}
return ""
}
func boolFromEnv(keys ...string) bool {
for _, k := range keys {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
continue
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return false
}
func startBirdMetricsPoller(ctx context.Context) {
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
if sock == "" {
+7
View File
@@ -15,3 +15,10 @@ WEBUI_DOMAIN=bgp.example.com
WEBUI_IP_WHITELIST=203.0.113.10/32
LETSENCRYPT_EMAIL=[email protected]
CF_DNS_API_TOKEN=
# Auth-portal SSO → контейнер evobgp-all (не VITE_* — они только для build web)
AUTH_REQUIRED=true
AUTH_JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
EVOBGP_PORTAL_TENANT_ID=
@@ -27,3 +27,16 @@ AUTO_UPDATE_INTERVAL_SEC=300
AUTO_UPDATE_SERVICES=evobgp-all,evobgp-web
# Защищенные сервисы, которые updater никогда не перезапускает
AUTO_UPDATE_PROTECTED_SERVICES=bird2
# ---------------------------------------------------------------------------
# Auth-portal SSO (прокидывается в контейнер evobgp-all)
# VITE_* в runtime .env НЕ влияют на уже собранный web-образ —
# UI читает GET /v1/auth/config с API (AUTH_REQUIRED / AUTH_PORTAL_URL).
# ---------------------------------------------------------------------------
AUTH_REQUIRED=true
AUTH_JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
# UUID tenant из БД (обязателен для JWT). При EVOBGP_SEED_DEMO=1 смотрите лог
# старта evobgp-all / SELECT id FROM tenant LIMIT 1;
EVOBGP_PORTAL_TENANT_ID=
@@ -136,6 +136,11 @@ services:
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
EVOBGP_DEV_INSECURE: "1"
volumes:
- bird_etc:/etc/bird
+6
View File
@@ -138,6 +138,12 @@ services:
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
# Portal SSO (JWT) — см. docs/access.md / auth-portal integrate-evobgp.md
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
# DEV ONLY — не для production (см. docs/access.md).
EVOBGP_DEV_INSECURE: "1"
volumes:
+34
View File
@@ -2,6 +2,40 @@
Как выдавать доступ к control plane API, веб-клиентам и репликам BIRD (`evobgp-node`). Секреты храните в менеджере секретов, переменных окружения оркестратора или зашифрованных файлах — не коммитьте реальные ключи в Git.
## Portal SSO (JWT)
Единый вход через **auth-portal** (app id `bgp`). См. [integrate-evobgp.md](https://git.shts.su/denozord/auth-portal/src/branch/main/docs/integrate-evobgp.md) в репозитории auth-portal.
| Переменная | Назначение |
|------------|------------|
| `AUTH_REQUIRED` / `EVOBGP_AUTH_REQUIRED` | Включить проверку portal JWT для UI |
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) |
| `EVOBGP_PORTAL_TENANT_ID` | Fallback tenant для portal JWT, если в токене нет `bgp_tenant_id` / `tenants.bgp` |
Источник tenant (по приоритету):
1. JWT claim `tenants.bgp` или `bgp_tenant_id` (задаётся в auth-portal → **Админ → Приложения** → поле «EvoBGP tenant ID»)
2. Env `EVOBGP_PORTAL_TENANT_ID`
Compose: переменные `AUTH_*` / `EVOBGP_PORTAL_TENANT_ID` должны быть в `environment:` сервиса **`evobgp-all`** (см. `deploy/compose/stack.microvps-full.yaml`). Просто положить их в `.env` без проброса в контейнер недостаточно.
`VITE_AUTH_*` в runtime `.env` **не** меняют уже собранный `evobgp-web` образ. UI берёт режим из `GET /v1/auth/config` (`required``AUTH_REQUIRED`, `portal_url``AUTH_PORTAL_URL`).
Проверка после рестарта:
```bash
curl -sS https://bgp.shnt.top/v1/auth/config
# {"required":true,"portal_url":"https://auth.shnt.top"}
```
Права — строки `bgp:<section>:<action>` из каталога портала (dashboard, modules, lookup, network, …). Apply/rollback требуют `bgp:operations:admin`.
**Ownership:** modules, peers, firewall clients/rules с `created_by_user_id` видны создателю и portal `is_admin` (API keys — весь tenant).
UI: `VITE_AUTH_ENABLED`, `VITE_AUTH_PORTAL_URL`. App Switcher: `CURRENT_APP_ID=bgp`, конфиг с `GET {portal}/api/v1/app-switcher`.
## API-ключи (`EVOBGP_API_KEYS`)
Формат переменной окружения: список записей через **запятую** без пробелов внутри логики парсера (пробелы вокруг записей допускаются при обрезке). Каждая запись:
+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
+158 -2
View File
@@ -11,9 +11,15 @@ info:
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
**Роли** (матрица доступа): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
**Аутентификация (dual):**
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `tenants.bgp` / `bgp_tenant_id` или fallback `EVOBGP_PORTAL_TENANT_ID`.
Публично: `GET /v1/auth/config` → `{ required, portal_url }`.
Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа.
**Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
JWT permissions мапятся на ту же лестницу (`:read`→viewer, `:write`→editor, `:admin`→operator).
Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа / portal tenant env.
license:
name: Proprietary
identifier: LicenseRef-Proprietary
@@ -27,6 +33,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 +229,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 +765,100 @@ 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.
resolved_ip:
type: string
description: |
IP, полученный DNS-resolve domain-запроса, из-за которого появился этот матч.
Пусто для прямого IP-запроса и для FQDN entry/snapshot без resolve.
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"
resolved_ips:
type: array
description: IP-адреса после live DNS resolve (только для query_kind=domain; A/AAAA).
items:
type: string
BgpPeer:
type: object
required:
@@ -1455,6 +1563,10 @@ components:
description: UTC cron для автоочистки (по умолчанию `0 */6 * * *`).
runtime_logs_auto_mode:
$ref: "#/components/schemas/RuntimeLogCleanupMode"
ui_show_quick_actions:
type: boolean
description: Показывать блок «Быстрые действия» на дашборде (UI preference).
default: true
additionalProperties: true
RevisionDiff:
@@ -1776,6 +1888,50 @@ 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 есть);
затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка
каждого полученного IP так же, как для IP-запроса (ranges + все snapshots).
Community на матче: `entry.community_id || module.default_community_id` (entry)
или `PrefixRow.community_id` (snapshot), с join к справочнику communities.
Поля `resolved_ips` / `resolved_ip` заполняются только для domain-запросов
(после успешного DNS). Ошибка DNS не даёт 5xx: FQDN-слой всё равно возвращается.
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]
+100
View File
@@ -0,0 +1,100 @@
# UI Design Contract (ops apps)
Единый контракт для vps-tracker, CFDM и EvoBGP. Surface: **ReUI Frame**. Kit API: `apps/web/src/components/reui-kit/`.
Карта: [llms.txt](https://reui.io/llms.txt) · [Styling](https://reui.io/docs/styling) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks)
## Surface
```ts
// apps/web/src/lib/ui-surface.ts
export const UI_SURFACE = 'frame' as const
```
Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как shell. Не смешивать Card и Frame на одном ops-экране.
## Canonical PRO references
| Зона | Block | Preview |
|------|-------|---------|
| Shell | `app-shell-12` (+ cmdk/monitor где нужно) | https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7 |
| KPI | horizontal compact hybrid (icon left + label/Badge + value ± variant; EvoBGP visual) | https://reui.io/preview/base/stats-12 |
| Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 |
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | `settings-16` + SettingRow (`settings-7`) | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-7 |
| 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/`)
| Component | Role |
|-----------|------|
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
| `QuickActionGrid` | KPI-like quick action tiles under KPI (gated by `ui_show_quick_actions`) |
| `OpsDashboard` | KPI + charts + attention queue |
| `SettingsShell` | settings nav + Outlet |
| `DetailPanel` | detail Frame sections |
| `filter-utils` | apply/clear ReUI Filters |
## Dashboard layout
| App | Section order |
|-----|---------------|
| EvoBGP / CFDM | KPI → **QuickActionGrid** → charts / rest |
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
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: source of truth — auth-portal `GET /api/v1/app-switcher`. Id: `bgp`. Admin: portal `/admin/apps`.
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` (после AppsMenu). Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
## MCP workflow
1. MCP `user-reui``search` / `get_block` / `get_component` with `surface: "frame"`
2. Cite `previewUrl` + `docsUrl`
3. CLI from `apps/web`: `pnpm dlx shadcn@latest add @reui/...`
4. Adapt into kit — do not hand-roll KPI/grid/settings rows
5. `validate_usage` / `get_audit_checklist`
Primitives: MCP `plugin-shadcn-shadcn` + `@evobgp/ui`.
## Spacing
- 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-*`
## Forbidden
- Card as ops list/dashboard shell
- Hand-rolled data tables when ReUI DataGrid exists
- Hand-rolled KPI grids when `KpiStatGrid` exists
- Mixing Card and Frame surfaces on one ops screen
+1
View File
@@ -14,6 +14,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+2
View File
@@ -7,6 +7,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+147 -7
View File
@@ -6,18 +6,32 @@ import (
"strings"
"evobgp/internal/authkey"
"github.com/golang-jwt/jwt/v5"
)
type ctxKey int
const authCtxKey ctxKey = 1
// Auth kinds distinguish API key sessions from portal JWT sessions.
const (
AuthKindAPIKey = "apikey"
AuthKindJWT = "jwt"
)
// Auth holds resolved API identity for a request.
type Auth struct {
TenantID string
Role string // viewer, editor, operator, node
Role string // viewer, editor, operator, node, firewall (apikeys only)
Token string
APIKeyID string // non-empty for DB-managed keys
// Portal / dual-auth fields (empty for API keys unless noted).
Kind string // "apikey" | "jwt"
UserID string // JWT sub
Email string // JWT email claim
Permissions []string // JWT permissions claim (bgp:*)
IsAdmin bool // JWT is_admin claim
}
func authFromContext(ctx context.Context) (Auth, bool) {
@@ -56,6 +70,23 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord {
return out
}
// looksLikeJWT reports whether raw is a compact JWS (three dot-separated segments, non-empty).
func looksLikeJWT(raw string) bool {
if raw == "" {
return false
}
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return false
}
for _, p := range parts {
if strings.TrimSpace(p) == "" {
return false
}
}
return true
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
@@ -65,6 +96,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
if looksLikeJWT(raw) && strings.TrimSpace(s.jwtSecret) != "" {
a, status, msg, ok := s.resolveJWT(raw)
if !ok {
writeProblem(w, status, http.StatusText(status), msg)
return
}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
a, ok := s.resolveAuth(raw)
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
@@ -79,10 +120,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
}
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID}
return Auth{
Kind: AuthKindAPIKey,
TenantID: rec.tenantID,
Role: rec.role,
Token: raw,
APIKeyID: rec.keyID,
}
}
// resolveAuth maps a bearer token to tenant identity.
// resolveAuth maps a bearer token to tenant identity (API key path).
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
// is available; env/DB mapping is used only when demo tenant is absent.
func (s *Server) resolveAuth(raw string) (Auth, bool) {
@@ -99,23 +146,115 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
if !ok {
if s.firewallResolver != nil {
if fw, ok := s.firewallResolver.Lookup(raw); ok {
return Auth{TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
return Auth{Kind: AuthKindAPIKey, TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
}
}
if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil {
return Auth{TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
return Auth{Kind: AuthKindAPIKey, TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
}
return Auth{}, false
}
return authFromKeyRecord(raw, rec), true
}
// resolveJWT parses and validates a portal HS256 token, returning an Auth on success.
// Returns (auth, status, detail, ok). status/detail are used when ok=false.
func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(s.jwtSecret), nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil || tok == nil || !tok.Valid {
return Auth{}, http.StatusUnauthorized, "invalid jwt", false
}
claims, ok := tok.Claims.(jwt.MapClaims)
if !ok {
return Auth{}, http.StatusUnauthorized, "invalid jwt claims", false
}
if iss := strings.TrimSpace(s.authIssuer); iss != "" {
got, _ := claims["iss"].(string)
if strings.TrimSpace(got) != iss {
return Auth{}, http.StatusUnauthorized, "jwt issuer mismatch", false
}
}
apps := coerceStringSlice(claims["apps"])
if !containsFold(apps, "bgp") {
return Auth{}, http.StatusForbidden, "jwt does not grant access to bgp app", false
}
sub, _ := claims["sub"].(string)
if strings.TrimSpace(sub) == "" {
return Auth{}, http.StatusUnauthorized, "jwt missing sub", false
}
tenantID := tenantIDFromClaims(claims)
if tenantID == "" {
tenantID = strings.TrimSpace(s.portalTenantID)
}
if tenantID == "" {
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (set bgp tenant in auth-portal App Switcher or EVOBGP_PORTAL_TENANT_ID)", false
}
email, _ := claims["email"].(string)
perms := coerceStringSlice(claims["permissions"])
isAdmin, _ := claims["is_admin"].(bool)
return Auth{
Kind: AuthKindJWT,
TenantID: tenantID,
UserID: strings.TrimSpace(sub),
Email: strings.TrimSpace(email),
Permissions: perms,
IsAdmin: isAdmin,
Token: raw,
}, 0, "", true
}
// tenantIDFromClaims prefers tenants.bgp, then bgp_tenant_id.
func tenantIDFromClaims(claims jwt.MapClaims) string {
if m, ok := claims["tenants"].(map[string]any); ok {
if v, ok := m["bgp"].(string); ok {
if tid := strings.TrimSpace(v); tid != "" {
return tid
}
}
}
if v, ok := claims["bgp_tenant_id"].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
func coerceStringSlice(v any) []string {
switch t := v.(type) {
case []string:
return t
case []any:
out := make([]string, 0, len(t))
for _, x := range t {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func containsFold(items []string, needle string) bool {
for _, x := range items {
if strings.EqualFold(strings.TrimSpace(x), needle) {
return true
}
}
return false
}
func (s *Server) devAuth() (Auth, bool) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid == "" {
return Auth{}, false
}
return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true
return Auth{Kind: AuthKindAPIKey, TenantID: tid, Role: "operator", Token: "dev"}, true
}
func roleLevel(role string) int {
@@ -131,7 +270,8 @@ func roleLevel(role string) int {
}
}
// requireAtLeast rejects node role and enforces viewer/editor/operator ladder.
// requireAtLeast rejects node/firewall roles and enforces viewer/editor/operator ladder for API keys.
// New code should call requirePerm which supports JWT permissions in addition to API-key roles.
func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool {
if strings.ToLower(a.Role) == "node" {
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
+302
View File
@@ -0,0 +1,302 @@
package httpapi
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
testJWTSecret = "test-secret-32-bytes-long-abcdef"
testIssuer = "https://auth.test.local"
)
func signTestJWT(t *testing.T, claims jwt.MapClaims) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, err := tok.SignedString([]byte(testJWTSecret))
if err != nil {
t.Fatalf("sign jwt: %v", err)
}
return s
}
func newJWTTestServer(t *testing.T) (*Server, string) {
t.Helper()
srv, err := New(Options{
SeedDemo: true,
BundleSeedHex: testBundleSeed,
JWTSecret: testJWTSecret,
AuthIssuer: testIssuer,
AuthPortalURL: "https://portal.test.local",
AuthRequired: true,
PortalTenantID: "", // filled after DemoIDs
})
if err != nil {
t.Fatal(err)
}
tenant, _, _, _, _ := srv.Store().DemoIDs()
// Override tenant to match seed.
srv.portalTenantID = tenant
return srv, tenant
}
func TestAuthJWTAcceptedWithBGPApp(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"email": "[email protected]",
"apps": []string{"bgp"},
"permissions": []string{"bgp:modules:read"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
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 body=%s", resp.StatusCode, b)
}
}
func TestAuthJWTRejectedOnWrongIssuer(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": "https://other.example.com",
"sub": "user-1",
"apps": []string{"bgp"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status=%d want 401", resp.StatusCode)
}
}
func TestAuthJWTRejectedWhenBGPAppMissing(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"apps": []string{"cfdm", "portal"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status=%d want 403", resp.StatusCode)
}
}
func TestAuthJWTIsAdminBypassesPermissions(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "admin-1",
"apps": []string{"bgp"},
"is_admin": true,
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
req.Header.Set("Authorization", "Bearer "+token)
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 body=%s", resp.StatusCode, b)
}
}
func TestAuthJWTMissingPermissionRejected(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"apps": []string{"bgp"},
"permissions": []string{"bgp:modules:read"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status=%d want 403", resp.StatusCode)
}
}
func TestAuthJWTTenantFromClaimWithoutEnv(t *testing.T) {
srv, err := New(Options{
SeedDemo: true,
BundleSeedHex: testBundleSeed,
JWTSecret: testJWTSecret,
AuthIssuer: testIssuer,
AuthPortalURL: "https://portal.test.local",
AuthRequired: true,
// No PortalTenantID — must come from JWT claim.
})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"apps": []string{"bgp"},
"permissions": []string{"bgp:modules:read"},
"bgp_tenant_id": tenant,
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
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 body=%s", resp.StatusCode, b)
}
}
func TestAuthJWTRejectedWhenTenantMissing(t *testing.T) {
srv, err := New(Options{
SeedDemo: true,
BundleSeedHex: testBundleSeed,
JWTSecret: testJWTSecret,
AuthIssuer: testIssuer,
AuthPortalURL: "https://portal.test.local",
AuthRequired: true,
})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"apps": []string{"bgp"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503", resp.StatusCode)
}
}
func TestAuthConfigPublic(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/config", nil)
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 body=%s", resp.StatusCode, b)
}
}
func TestHasPermissionSupersets(t *testing.T) {
cases := []struct {
name string
granted []string
want string
ok bool
}{
{"exact-read", []string{"bgp:modules:read"}, "bgp:modules:read", true},
{"write-covers-read", []string{"bgp:modules:write"}, "bgp:modules:read", true},
{"admin-covers-write", []string{"bgp:modules:admin"}, "bgp:modules:write", true},
{"read-does-not-cover-write", []string{"bgp:modules:read"}, "bgp:modules:write", false},
{"different-section", []string{"bgp:network:admin"}, "bgp:modules:read", false},
{"empty-granted", nil, "bgp:modules:read", false},
{"malformed-required", []string{"bgp:modules:admin"}, "bgp:modules", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := HasPermission(tc.granted, tc.want); got != tc.ok {
t.Fatalf("HasPermission(%v, %q) = %v, want %v", tc.granted, tc.want, got, tc.ok)
}
})
}
}
+107
View File
@@ -0,0 +1,107 @@
package httpapi
import (
"net/http"
"strings"
)
// Portal permission strings. Format: <app>:<section>:<level> (bgp:modules:read).
// Superset order: admin ⊃ write ⊃ read for the same <app>:<section>.
const (
permLevelRead = "read"
permLevelWrite = "write"
permLevelAdmin = "admin"
)
// permLevelRank returns 0 for unknown, 1 for read, 2 for write, 3 for admin.
func permLevelRank(level string) int {
switch strings.ToLower(strings.TrimSpace(level)) {
case permLevelRead:
return 1
case permLevelWrite:
return 2
case permLevelAdmin:
return 3
default:
return 0
}
}
// splitPerm splits a permission string into (app, section, level).
func splitPerm(perm string) (app, section, level string, ok bool) {
parts := strings.Split(strings.TrimSpace(perm), ":")
if len(parts) != 3 {
return "", "", "", false
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), true
}
// HasPermission reports whether the granted list satisfies required, applying the
// admin ⊃ write ⊃ read superset within the same app+section.
func HasPermission(granted []string, required string) bool {
rApp, rSection, rLevel, ok := splitPerm(required)
if !ok {
return false
}
needRank := permLevelRank(rLevel)
if needRank == 0 {
return false
}
for _, g := range granted {
gApp, gSection, gLevel, ok := splitPerm(g)
if !ok {
continue
}
if !strings.EqualFold(gApp, rApp) || !strings.EqualFold(gSection, rSection) {
continue
}
if permLevelRank(gLevel) >= needRank {
return true
}
}
return false
}
// permAPIKeyRoleFor maps a permission level to the API-key role required.
func permAPIKeyRoleFor(perm string) string {
_, _, level, ok := splitPerm(perm)
if !ok {
return "operator"
}
switch strings.ToLower(level) {
case permLevelRead:
return "viewer"
case permLevelWrite:
return "editor"
case permLevelAdmin:
return "operator"
default:
return "operator"
}
}
// requirePerm enforces a permission for a portal JWT or falls back to the API-key role ladder.
// node/firewall roles are always rejected (they use requireNode / requireFirewall).
func (s *Server) requirePerm(w http.ResponseWriter, a Auth, perm string) bool {
switch strings.ToLower(a.Role) {
case "node":
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
return false
case "firewall":
writeProblem(w, http.StatusForbidden, "Forbidden", "firewall role cannot access this resource")
return false
}
if a.Kind == AuthKindJWT || len(a.Permissions) > 0 {
if a.IsAdmin || HasPermission(a.Permissions, perm) {
return true
}
writeProblem(w, http.StatusForbidden, "Forbidden", "missing permission: "+perm)
return false
}
need := permAPIKeyRoleFor(perm)
if roleLevel(a.Role) < roleLevel(need) {
writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role")
return false
}
return true
}
+40 -41
View File
@@ -35,6 +35,7 @@ func (s *Server) Handler() http.Handler {
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic)
s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript)
s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript)
@@ -54,6 +55,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)
@@ -92,6 +94,15 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleAuthConfigPublic exposes portal-auth wiring so the UI can decide whether to redirect to the login portal.
// Registered on the public mux (no auth middleware): safe to call without a bearer token.
func (s *Server) handleAuthConfigPublic(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"required": s.authRequired,
"portal_url": s.authPortalURL,
})
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
checks := map[string]string{"store": "ok", "jobs": "memory"}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
@@ -187,7 +198,7 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:modules:read") {
return
}
typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
@@ -203,23 +214,9 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
}
filtered := make([]*store.Module, 0)
limit := parseListLimit(r)
cursor := r.URL.Query().Get("cursor")
if typeFilter == "" && enabledFilter == nil {
page, next, more := s.store.ListModulesPage(a.TenantID, cursor, limit)
for _, mod := range page {
filtered = append(filtered, mod)
}
items := make([]map[string]any, 0, len(filtered))
for _, mod := range filtered {
items = append(items, moduleJSON(mod))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
})
return
}
for _, mod := range s.store.ListModules(a.TenantID) {
all := s.store.ListModules(a.TenantID)
all = store.FilterOwned(all, func(m *store.Module) string { return m.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
for _, mod := range all {
if typeFilter != "" && mod.Type != typeFilter {
continue
}
@@ -244,7 +241,7 @@ func (s *Server) handleRouterListsCatalog(w http.ResponseWriter, r *http.Request
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:directories:read") {
return
}
cat, err := reports.BuildRouterListsCatalog(s.store, a.TenantID)
@@ -267,10 +264,14 @@ func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:modules:read") {
return
}
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
if err == nil && !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, mod.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
if err != nil {
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
@@ -288,10 +289,11 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:network:read") {
return
}
allPeers := s.store.ListPeers(a.TenantID)
allPeers = store.FilterOwned(allPeers, func(p *store.BGPPeer) string { return p.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
@@ -391,7 +393,7 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:network:read") {
return
}
speakers := s.store.ListSpeakersForTenant(a.TenantID)
@@ -423,7 +425,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
if !s.requirePerm(w, a, "bgp:modules:write") {
return
}
moduleID := r.PathValue("module_id")
@@ -459,7 +461,7 @@ func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
if !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body struct {
@@ -513,7 +515,7 @@ func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
@@ -586,7 +588,7 @@ func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
rev, err := s.store.GetRevisionSummary(a.TenantID, r.PathValue("revision_id"))
@@ -603,7 +605,7 @@ func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
@@ -642,7 +644,7 @@ func (s *Server) handleRevisionDiagnosticLog(w http.ResponseWriter, r *http.Requ
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
revID := r.PathValue("revision_id")
@@ -668,7 +670,7 @@ func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
d, err := s.store.RevisionDiff(a.TenantID, r.PathValue("revision_a"), r.PathValue("revision_b"))
@@ -685,7 +687,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "operator") {
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
revID := r.PathValue("revision_id")
@@ -716,8 +718,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
var body struct {
@@ -763,8 +764,7 @@ func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
spkID := r.PathValue("id")
@@ -814,8 +814,7 @@ func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
@@ -838,7 +837,7 @@ func (s *Server) handleBirdStatus(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:monitoring:read") {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
@@ -853,7 +852,7 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
@@ -876,7 +875,7 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
@@ -893,7 +892,7 @@ func (s *Server) handleGetJobReport(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
@@ -931,7 +930,7 @@ func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
j, err := s.jobs.RequestCancel(a.TenantID, r.PathValue("job_id"))
+18 -9
View File
@@ -21,13 +21,22 @@ func (s *Server) registerAPIKeyRoutes(m *http.ServeMux) {
func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
writeJSON(w, http.StatusOK, map[string]any{
resp := map[string]any{
"tenant_id": a.TenantID,
"role": a.Role,
})
"kind": a.Kind,
}
if a.Kind == AuthKindJWT {
resp["user_id"] = a.UserID
resp["email"] = a.Email
resp["permissions"] = a.Permissions
resp["is_admin"] = a.IsAdmin
}
writeJSON(w, http.StatusOK, resp)
}
func apiKeyJSON(k *store.APIKey) map[string]any {
@@ -59,7 +68,7 @@ func apiKeyJSON(k *store.APIKey) map[string]any {
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
list, err := s.store.ListAPIKeys(a.TenantID)
@@ -74,7 +83,7 @@ func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
k, err := s.store.GetAPIKey(a.TenantID, r.PathValue("id"))
@@ -87,7 +96,7 @@ func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
var body struct {
@@ -127,7 +136,7 @@ func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
var raw map[string]json.RawMessage
@@ -183,7 +192,7 @@ func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
@@ -199,7 +208,7 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
rotated, err := s.store.RotateAPIKey(a.TenantID, r.PathValue("id"))
+88 -49
View File
@@ -80,7 +80,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body struct {
@@ -99,12 +99,16 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mod, err := s.store.CreateModule(a.TenantID, &store.Module{
newModule := &store.Module{
Type: body.Type, Name: body.Name, Enabled: body.Enabled, Priority: body.Priority,
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
DohProfileIDs: body.DohProfileIDs, DohResolverPolicy: body.DohResolverPolicy,
})
}
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
newModule.CreatedByUserID = a.UserID
}
mod, err := s.store.CreateModule(a.TenantID, newModule)
if err != nil {
writeStoreErr(w, err)
return
@@ -114,7 +118,7 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
rawBody, err := io.ReadAll(r.Body)
@@ -158,7 +162,14 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
body.RefreshIntervalSec = &zero
}
}
mod, err := s.store.UpdateModule(a.TenantID, r.PathValue("module_id"), &body)
moduleID := r.PathValue("module_id")
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
}
mod, err := s.store.UpdateModule(a.TenantID, moduleID, &body)
if err != nil {
writeStoreErr(w, err)
return
@@ -168,10 +179,17 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
if err := s.store.SoftDeleteModule(a.TenantID, r.PathValue("module_id")); err != nil {
moduleID := r.PathValue("module_id")
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
}
if err := s.store.SoftDeleteModule(a.TenantID, moduleID); err != nil {
writeStoreErr(w, err)
return
}
@@ -215,7 +233,7 @@ func writePostgresStoreErr(w http.ResponseWriter, err error) bool {
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListCDNSources(a.TenantID, r.PathValue("module_id"))
@@ -248,7 +266,7 @@ func cdnSourceJSON(x *store.CDNSource) map[string]any {
func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body struct {
@@ -327,7 +345,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.CDNSource
@@ -357,7 +375,7 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.CDNSourcePatch
@@ -387,7 +405,7 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -401,7 +419,7 @@ func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListASEntries(a.TenantID, r.PathValue("module_id"))
@@ -439,7 +457,7 @@ func asEntryJSON(x *store.ASEntry) map[string]any {
func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.ASEntry
@@ -459,7 +477,7 @@ func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.ASEntryPatch
@@ -479,7 +497,7 @@ func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -493,7 +511,7 @@ func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListDomainEntries(a.TenantID, r.PathValue("module_id"))
@@ -516,7 +534,7 @@ func domainEntryJSON(x *store.DomainEntry) map[string]any {
func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.DomainEntry
@@ -536,7 +554,7 @@ func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.DomainEntryPatch
@@ -556,7 +574,7 @@ func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -570,7 +588,7 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListIPRangeEntries(a.TenantID, r.PathValue("module_id"))
@@ -593,7 +611,7 @@ func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.IPRangeEntry
@@ -613,7 +631,7 @@ func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.IPRangePatch
@@ -633,7 +651,7 @@ func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -647,7 +665,7 @@ func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
moduleID := r.PathValue("module_id")
@@ -730,7 +748,7 @@ func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
moduleID := r.PathValue("module_id")
@@ -774,7 +792,7 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
func (s *Server) handleListDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
list, err := s.store.ListDohProfiles(a.TenantID)
@@ -806,7 +824,7 @@ func dohJSON(x *store.DohProfile) map[string]any {
func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
x, err := s.store.GetDohProfile(a.TenantID, r.PathValue("id"))
@@ -819,7 +837,7 @@ func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.DohProfile
@@ -837,7 +855,7 @@ func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.DohProfilePatch
@@ -855,7 +873,7 @@ func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
@@ -867,7 +885,7 @@ func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
list, err := s.store.ListCommunities(a.TenantID)
@@ -892,7 +910,7 @@ func commJSON(x *store.Community) map[string]any {
func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
x, err := s.store.GetCommunity(a.TenantID, r.PathValue("id"))
@@ -905,7 +923,7 @@ func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.Community
@@ -923,7 +941,7 @@ func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.CommunityPatch
@@ -941,7 +959,7 @@ func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
@@ -953,7 +971,7 @@ func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.BGPPeer
@@ -962,6 +980,9 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
return
}
body.TenantID = a.TenantID
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
body.CreatedByUserID = a.UserID
}
x, err := s.store.CreatePeer(a.TenantID, &body)
if err != nil {
writeStoreErr(w, err)
@@ -973,7 +994,7 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
return
}
x, err := s.store.GetPeer(a.TenantID, r.PathValue("id"))
@@ -981,12 +1002,16 @@ func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, x.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
return
}
writeJSON(w, http.StatusOK, peerJSON(x))
}
func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.PeerPatch
@@ -994,7 +1019,14 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdatePeer(a.TenantID, r.PathValue("id"), &body)
peerID := r.PathValue("id")
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
return
}
}
x, err := s.store.UpdatePeer(a.TenantID, peerID, &body)
if err != nil {
writeStoreErr(w, err)
return
@@ -1005,10 +1037,17 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
if err := s.store.DeletePeer(a.TenantID, r.PathValue("id")); err != nil {
peerID := r.PathValue("id")
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
return
}
}
if err := s.store.DeletePeer(a.TenantID, peerID); err != nil {
writeStoreErr(w, err)
return
}
@@ -1018,7 +1057,7 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.Speaker
@@ -1044,7 +1083,7 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
return
}
x, err := s.store.GetSpeaker(a.TenantID, r.PathValue("speaker_id"))
@@ -1057,7 +1096,7 @@ func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.SpeakerPatch
@@ -1075,7 +1114,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
@@ -1087,7 +1126,7 @@ func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:operations:read") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
@@ -1111,7 +1150,7 @@ func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request)
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
return
}
m, err := s.store.ListGlobalSettings(a.TenantID)
@@ -1124,7 +1163,7 @@ func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
return
}
var body map[string]any
+61 -15
View File
@@ -43,7 +43,7 @@ func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
seed := strings.TrimSpace(s.bundleSeedHex)
@@ -187,7 +187,7 @@ func readFirewallScript(name string) ([]byte, error) {
func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
items, err := s.store.ListFirewallClients(a.TenantID)
@@ -195,12 +195,13 @@ func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Reques
writeInternalError(w, "internal", err)
return
}
items = store.FilterOwned(items, func(c *store.FirewallClient) string { return c.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
@@ -209,15 +210,25 @@ func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request)
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, client.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
var patch store.FirewallClientPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
@@ -233,10 +244,16 @@ func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Reques
func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
client, err := s.store.ApproveFirewallClient(a.TenantID, id, a.APIKeyID)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
@@ -249,10 +266,16 @@ func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Requ
func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
if err := s.store.RevokeFirewallClient(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
@@ -264,10 +287,16 @@ func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Reque
func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
if err := s.store.DeleteFirewallClient(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
@@ -279,7 +308,7 @@ func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Reque
func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
@@ -297,12 +326,13 @@ func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request)
writeInternalError(w, "internal", err)
return
}
items = store.FilterOwned(items, func(rule *store.FirewallRule) string { return rule.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
var body struct {
@@ -326,12 +356,16 @@ func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request
cid := strings.TrimSpace(*body.ClientID)
clientID = &cid
}
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, &store.FirewallRuleCreate{
fwRule := &store.FirewallRuleCreate{
Priority: body.Priority,
Action: body.Action,
CommunityID: body.CommunityID,
Comment: body.Comment,
})
}
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
fwRule.CreatedByUserID = a.UserID
}
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, fwRule)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid rule")
return
@@ -342,10 +376,16 @@ func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request
func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
}
var patch store.FirewallRulePatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
@@ -362,10 +402,16 @@ func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request)
func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
}
if err := s.store.DeleteFirewallRule(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
@@ -376,7 +422,7 @@ func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request
func (s *Server) handleReorderFirewallRules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
var body struct {
@@ -494,7 +540,7 @@ func (s *Server) handleFirewallHeartbeat(w http.ResponseWriter, r *http.Request)
func (s *Server) handleFirewallClientPreview(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
+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.requirePerm(w, a, "bgp:lookup:read") {
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(r.Context(), s.store, a.TenantID, q)
if err != nil {
if errors.Is(err, store.ErrInvalidInput) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "query must be an IP address or FQDN")
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)
}
}
+4 -4
View File
@@ -62,7 +62,7 @@ func maintenancePolicyJSON(p *store.MaintenancePolicy) map[string]any {
func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
cursor := r.URL.Query().Get("cursor")
@@ -81,7 +81,7 @@ func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Re
func (s *Server) handleGetMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
p, err := s.store.GetMaintenancePolicy(r.PathValue("id"))
@@ -163,7 +163,7 @@ func (s *Server) handleDeleteMaintenancePolicy(w http.ResponseWriter, r *http.Re
func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
if s.maintStats == nil {
@@ -185,7 +185,7 @@ func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Req
func (s *Server) handleListMaintenanceConfigAudit(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
cursor := r.URL.Query().Get("cursor")
@@ -27,12 +27,10 @@ func (s *Server) registerPostgresMaintenanceRoutes(m *http.ServeMux) {
m.HandleFunc("GET /postgres/maintenance/logs", s.handlePostgresMaintenanceLogs)
}
// requireOperatorStrict is a compatibility shim mapping the legacy "operator"
// API-key role to the tenant-settings admin permission for JWT/API-key clients.
func (s *Server) requireOperatorStrict(w http.ResponseWriter, a Auth) bool {
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return false
}
return true
return s.requirePerm(w, a, "bgp:tenant_settings:admin")
}
func (s *Server) checkPgMaintRateLimit(tenantID, kind string) bool {
@@ -201,7 +199,7 @@ func (s *Server) handlePostgresCleanup(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresMaintenanceLogs(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
cursor := r.URL.Query().Get("cursor")
@@ -35,7 +35,7 @@ func parseLimitQuery(r *http.Request, def, max int) int {
func (s *Server) handlePostgresOverview(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -50,7 +50,7 @@ func (s *Server) handlePostgresOverview(w http.ResponseWriter, r *http.Request)
func (s *Server) handlePostgresQueries(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -65,7 +65,7 @@ func (s *Server) handlePostgresQueries(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresLocks(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -80,7 +80,7 @@ func (s *Server) handlePostgresLocks(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresTables(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -95,7 +95,7 @@ func (s *Server) handlePostgresTables(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresRecommendations(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
@@ -110,7 +110,7 @@ func (s *Server) handlePostgresRecommendations(w http.ResponseWriter, r *http.Re
func (s *Server) handleMonitoringCorrelation(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
window := 60
+2 -2
View File
@@ -52,7 +52,7 @@ func (s *Server) resolveRevisionRetentionMinutesBody(r *http.Request, tenantID s
func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:operations:read") {
return
}
minutes, valid := s.resolveRevisionRetentionMinutesQuery(r, a.TenantID)
@@ -78,7 +78,7 @@ func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Requ
func (s *Server) handleRevisionPrune(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
minutes, valid := s.resolveRevisionRetentionMinutesBody(r, a.TenantID)
+6 -6
View File
@@ -70,7 +70,7 @@ func runtimeLogCleanupAuditJSON(row *store.RuntimeLogCleanupAudit) map[string]an
func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requireRuntimeLogs(w) {
return
}
items, err := s.runtimeLogs.ListFiles()
@@ -87,7 +87,7 @@ func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Reques
func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requireRuntimeLogs(w) {
return
}
filename := r.PathValue("filename")
@@ -111,7 +111,7 @@ func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request)
func (s *Server) handleDeleteRuntimeLogFile(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
return
}
filename := r.PathValue("filename")
@@ -157,7 +157,7 @@ func (s *Server) runtimeLogAutoPolicy(w http.ResponseWriter, r *http.Request, te
func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
return
}
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
@@ -199,7 +199,7 @@ func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Req
func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
return
}
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
@@ -221,7 +221,7 @@ func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request)
func (s *Server) handleListRuntimeLogCleanupAudit(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") {
return
}
cursor := r.URL.Query().Get("cursor")
+22
View File
@@ -36,6 +36,13 @@ type Server struct {
runtimeLogs *runtimelogs.Service
runtimeLogsPolicyTenant string
mux *http.ServeMux
// Portal / dual-auth (JWT) configuration.
jwtSecret string
authIssuer string
authPortalURL string
portalTenantID string
authRequired bool
}
// Options configures the API server.
@@ -49,6 +56,13 @@ type Options struct {
CORSAllowedOrigins string
// RuntimeLogsPolicyTenant overrides tenant for auto-cleanup scheduler settings (optional).
RuntimeLogsPolicyTenant string
// Portal / dual-auth (JWT) — leave empty to disable JWT path.
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top)
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
PortalTenantID string // fallback when JWT has no bgp_tenant_id / tenants.bgp
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
}
// New constructs Server and wiring for async jobs.
@@ -104,6 +118,14 @@ func New(opts Options) (*Server, error) {
cdnHTTP: NewCDNHTTPClient(),
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
runtimeLogsPolicyTenant: strings.TrimSpace(opts.RuntimeLogsPolicyTenant),
jwtSecret: strings.TrimSpace(opts.JWTSecret),
authIssuer: strings.TrimSpace(opts.AuthIssuer),
authPortalURL: strings.TrimSpace(opts.AuthPortalURL),
portalTenantID: strings.TrimSpace(opts.PortalTenantID),
authRequired: opts.AuthRequired,
}
if s.authIssuer == "" {
s.authIssuer = "https://auth.shnt.top"
}
s.mux = http.NewServeMux()
s.registerRoutes()
+1 -1
View File
@@ -71,7 +71,7 @@ func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
func (s *Server) handleBundleSigningPublicKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
return
}
writeJSON(w, http.StatusOK, map[string]any{
+369
View File
@@ -0,0 +1,369 @@
// Package lookup implements dual-layer membership checks for IP addresses and FQDNs
// against module entries and materialized prefix snapshots.
package lookup
import (
"context"
"fmt"
"net"
"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"`
// ResolvedIP is set when the hit came from a DNS-resolved address of a domain query.
ResolvedIP string `json:"resolved_ip,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"`
ResolvedIPs []string `json:"resolved_ips,omitempty"`
}
// DomainResolver resolves a hostname to IP addresses (A/AAAA).
type DomainResolver func(ctx context.Context, host string) ([]netip.Addr, error)
// Lookup checks whether q (IP or FQDN) is present in tenant lists (entries + snapshots).
// For domains, FQDN membership is checked first, then live DNS resolve and IP membership.
func Lookup(ctx context.Context, st store.Backend, tenantID, q string) (*Result, error) {
return LookupWithResolver(ctx, st, tenantID, q, systemDNSResolver)
}
// LookupWithResolver is like Lookup but uses resolve for domain→IP (tests / alternate DNS).
func LookupWithResolver(
ctx context.Context,
st store.Backend,
tenantID, q string,
resolve DomainResolver,
) (*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
}
if resolve == nil {
resolve = systemDNSResolver
}
if err := lookupResolvedIPs(ctx, st, tenantID, fqdn, out, commByID, resolve); err != nil {
return nil, err
}
}
out.MatchCount = len(out.Matches)
out.Matched = out.MatchCount > 0
return out, nil
}
func systemDNSResolver(ctx context.Context, host string) ([]netip.Addr, error) {
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, err
}
return uniqAddrs(ips), nil
}
func uniqAddrs(in []netip.Addr) []netip.Addr {
seen := make(map[netip.Addr]struct{}, len(in))
out := make([]netip.Addr, 0, len(in))
for _, a := range in {
a = a.Unmap()
if _, ok := seen[a]; ok {
continue
}
seen[a] = struct{}{}
out = append(out, a)
}
return out
}
func lookupResolvedIPs(
ctx context.Context,
st store.Backend,
tenantID, fqdn string,
out *Result,
commByID map[string]*store.Community,
resolve DomainResolver,
) error {
ips, err := resolve(ctx, fqdn)
if err != nil {
// DNS failure must not hide FQDN-layer matches already collected.
return nil
}
out.ResolvedIPs = make([]string, 0, len(ips))
for _, ip := range ips {
out.ResolvedIPs = append(out.ResolvedIPs, ip.String())
if err := lookupIP(st, tenantID, ip, out, commByID, ip.String()); err != nil {
return err
}
}
return nil
}
func lookupIP(
st store.Backend,
tenantID string,
addr netip.Addr,
out *Result,
commByID map[string]*store.Community,
resolvedIP string,
) 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),
ResolvedIP: resolvedIP,
}, 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,
ResolvedIP: resolvedIP,
}, 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: mid,
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
}
+262
View File
@@ -0,0 +1,262 @@
package lookup
import (
"context"
"errors"
"net/netip"
"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(context.Background(), 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(context.Background(), 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)
}
noDNS := func(context.Context, string) ([]netip.Addr, error) { return nil, nil }
res, err := LookupWithResolver(context.Background(), m, tenant, "example.com", noDNS)
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 TestLookupDomainResolvedIPAgainstRanges(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: "203.0.113.0/24",
CommunityID: &cid,
}); err != nil {
t.Fatal(err)
}
if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash-r", []store.PrefixRow{
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"},
}); err != nil {
t.Fatal(err)
}
fake := func(_ context.Context, host string) ([]netip.Addr, error) {
if host != "google.com" {
t.Fatalf("unexpected host %q", host)
}
return []netip.Addr{netip.MustParseAddr("203.0.113.50")}, nil
}
res, err := LookupWithResolver(context.Background(), m, tenant, "google.com", fake)
if err != nil {
t.Fatal(err)
}
if res.QueryKind != KindDomain {
t.Fatalf("kind: %+v", res)
}
if len(res.ResolvedIPs) != 1 || res.ResolvedIPs[0] != "203.0.113.50" {
t.Fatalf("resolved_ips: %+v", res.ResolvedIPs)
}
if !res.Matched {
t.Fatalf("expected IP membership via resolve, got %+v", res)
}
var viaResolve bool
for _, hit := range res.Matches {
if hit.ResolvedIP == "203.0.113.50" && hit.MatchedValue == "203.0.113.0/24" {
viaResolve = true
}
}
if !viaResolve {
t.Fatalf("missing resolved-ip match: %+v", res.Matches)
}
}
func TestLookupNoMatch(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
res, err := Lookup(context.Background(), 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()
ctx := context.Background()
_, err := Lookup(ctx, m, tenant, "")
if !errors.Is(err, store.ErrInvalidInput) {
t.Fatalf("empty: %v", err)
}
_, err = Lookup(ctx, m, tenant, "not a host")
if !errors.Is(err, store.ErrInvalidInput) {
t.Fatalf("spaces: %v", err)
}
_, err = Lookup(ctx, 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)
}
}
+46 -16
View File
@@ -119,7 +119,7 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
if err != nil {
return nil
@@ -133,7 +133,8 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
var doh, dc, cron *string
var refresh *int32
var last *time.Time
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
var createdBy *string
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
continue
}
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
@@ -153,6 +154,9 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
t := last.UTC()
m.LastRefreshedAt = &t
}
if createdBy != nil {
m.CreatedByUserID = strings.TrimSpace(*createdBy)
}
out = append(out, &m)
moduleByID[m.ID] = &m
}
@@ -175,7 +179,7 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL
ORDER BY priority, name
LIMIT $2 OFFSET $3`, tenantID, limit+1, off)
@@ -191,7 +195,8 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
var doh, dc, cron *string
var refresh *int32
var last *time.Time
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
var createdBy *string
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
continue
}
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
@@ -211,6 +216,9 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
t := last.UTC()
m.LastRefreshedAt = &t
}
if createdBy != nil {
m.CreatedByUserID = strings.TrimSpace(*createdBy)
}
out = append(out, &m)
moduleByID[m.ID] = &m
}
@@ -238,11 +246,12 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
var doh, dc, cron *string
var refresh *int32
var last *time.Time
var createdBy *string
err := p.pool.QueryRow(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last)
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
@@ -265,6 +274,9 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
t := last.UTC()
m.LastRefreshedAt = &t
}
if createdBy != nil {
m.CreatedByUserID = strings.TrimSpace(*createdBy)
}
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
if err := p.fillModuleDohFields(ctx, &m); err != nil {
return nil, err
@@ -299,10 +311,14 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
lastArg = in.LastRefreshedAt.UTC()
}
policy := store.NormalizeDohResolverPolicy(in.DohResolverPolicy)
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg)
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at, created_by_user_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg, createdBy)
if err != nil {
return nil, err
}
@@ -402,7 +418,8 @@ func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}'),
created_by_user_id
FROM bgp_peer WHERE tenant_id=$1 ORDER BY neighbor`, tenantID)
if err != nil {
return nil
@@ -412,10 +429,14 @@ func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer {
for rows.Next() {
var peer store.BGPPeer
var sp *string
if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON); err != nil {
var createdBy *string
if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON, &createdBy); err != nil {
continue
}
peer.SpeakerID = sp
if createdBy != nil {
peer.CreatedByUserID = strings.TrimSpace(*createdBy)
}
out = append(out, &peer)
}
return out
@@ -425,11 +446,13 @@ func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) {
ctx := context.Background()
var peer store.BGPPeer
var sp *string
var createdBy *string
err := p.pool.QueryRow(ctx, `
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}'),
created_by_user_id
FROM bgp_peer WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON)
&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON, &createdBy)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
@@ -437,6 +460,9 @@ func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) {
return nil, err
}
peer.SpeakerID = sp
if createdBy != nil {
peer.CreatedByUserID = strings.TrimSpace(*createdBy)
}
return &peer, nil
}
@@ -461,10 +487,14 @@ func (p *Postgres) CreatePeer(tenantID string, in *store.BGPPeer) (*store.BGPPee
sp = strings.TrimSpace(*in.SpeakerID)
}
enabled := store.EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState)
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json)
VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb)`,
id, tenantID, sp, neighbor, in.RemoteASN, enabled, pol, string(mb))
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json, created_by_user_id)
VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb, $9)`,
id, tenantID, sp, neighbor, in.RemoteASN, enabled, pol, string(mb), createdBy)
if err != nil {
return nil, err
}
+35 -21
View File
@@ -20,7 +20,7 @@ const firewallClientSelectCols = `
COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0),
COALESCE(last_apply_packets_dropped, 0), COALESCE(last_apply_packets_accepted, 0),
COALESCE(last_apply_source, ''),
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at`
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at, created_by_user_id`
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
ctx := context.Background()
@@ -63,11 +63,15 @@ func (p *Postgres) CreateFirewallClient(tenantID string, in *store.FirewallClien
}
id := uuid.NewString()
ctx := context.Background()
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version, created_by_user_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
id, tenantID, strings.TrimSpace(in.Name), strings.TrimSpace(in.Hostname),
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion))
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion), createdBy)
if err != nil {
return nil, err
}
@@ -232,11 +236,11 @@ func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*stor
var err error
if clientID == nil {
rows, err = p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE tenant_id=$1 AND client_id IS NULL ORDER BY priority`, tenantID)
} else {
rows, err = p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE tenant_id=$1 AND client_id=$2 ORDER BY priority`, tenantID, *clientID)
}
if err != nil {
@@ -249,7 +253,7 @@ func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*stor
func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*store.FirewallRule, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule
WHERE tenant_id=$1 AND (client_id IS NULL OR client_id=$2)
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, priority`, tenantID, clientID)
@@ -263,7 +267,7 @@ func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*
func (p *Postgres) ListAllFirewallRulesForReplication(tenantID string) ([]*store.FirewallRule, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE tenant_id=$1
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, client_id, priority`, tenantID)
if err != nil {
@@ -294,10 +298,14 @@ func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *sto
}
id := uuid.NewString()
ctx := context.Background()
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment))
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment, created_by_user_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment), createdBy)
if err != nil {
return nil, err
}
@@ -307,7 +315,7 @@ func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *sto
func (p *Postgres) GetFirewallRule(tenantID, id string) (*store.FirewallRule, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE id=$1 AND tenant_id=$2`, id, tenantID)
r, err := scanFirewallRuleRow(row.Scan, tenantID)
if err != nil {
@@ -423,19 +431,22 @@ func scanFirewallRules(rows pgx.Rows, tenantID string) ([]*store.FirewallRule, e
func scanFirewallRuleRow(scan scanFn, tenantID string) (*store.FirewallRule, error) {
var r store.FirewallRule
r.TenantID = tenantID
var clientID, communityID *string
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt); err != nil {
var clientID, communityID, createdBy *string
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt, &createdBy); err != nil {
return nil, err
}
r.ClientID = clientID
r.CommunityID = communityID
if createdBy != nil {
r.CreatedByUserID = strings.TrimSpace(*createdBy)
}
return &r, nil
}
func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient, error) {
var c store.FirewallClient
c.TenantID = tenantID
var approvedBy *string
var approvedBy, createdBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
@@ -444,16 +455,16 @@ func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &createdBy,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, createdBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
var c store.FirewallClient
var approvedBy *string
var approvedBy, createdBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
@@ -462,14 +473,14 @@ func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &createdBy,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, createdBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy, createdBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
c.LastSeenAt = lastSeen
c.LastApplyAt = lastApply
c.ApprovedAt = approved
@@ -477,6 +488,9 @@ func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, appr
if approvedBy != nil {
c.ApprovedByAPIKeyID = *approvedBy
}
if createdBy != nil {
c.CreatedByUserID = strings.TrimSpace(*createdBy)
}
if prefixCount != nil {
c.LastApplyPrefixCount = *prefixCount
}
+1
View File
@@ -150,6 +150,7 @@ type Backend interface {
ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error)
ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error)
CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error)
GetFirewallRule(tenantID, ruleID string) (*FirewallRule, error)
UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error)
DeleteFirewallRule(tenantID, ruleID string) error
ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error
+22 -18
View File
@@ -29,15 +29,17 @@ type FirewallClient struct {
ApprovedAt *time.Time `json:"approved_at,omitempty"`
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
}
// FirewallClientCreate is input for enroll (token hash supplied by caller).
type FirewallClientCreate struct {
Name string
Hostname string
TokenPrefix string
TokenHash []byte
ClientVersion string
Name string
Hostname string
TokenPrefix string
TokenHash []byte
ClientVersion string
CreatedByUserID string
}
// FirewallClientPatch is a partial update for operator edits.
@@ -62,23 +64,25 @@ type FirewallClientReplicationRow struct {
// FirewallRule is one block/accept policy rule.
type FirewallRule struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
ClientID *string `json:"client_id,omitempty"`
Priority int `json:"priority"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
ClientID *string `json:"client_id,omitempty"`
Priority int `json:"priority"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
}
// FirewallRuleCreate is input for creating a rule.
type FirewallRuleCreate struct {
Priority *int `json:"priority,omitempty"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
Priority *int `json:"priority,omitempty"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
CreatedByUserID string `json:"-"`
}
// FirewallRulePatch is a partial rule update.
+11 -9
View File
@@ -95,6 +95,7 @@ type Module struct {
DohResolverPolicy string
LastRefreshedAt *time.Time
DeletedAt *time.Time
CreatedByUserID string // portal JWT sub; empty = system / API key
}
type Revision struct {
@@ -112,15 +113,16 @@ type Revision struct {
// BGPPeer maps to bgp_peer (+ display fields in meta).
type BGPPeer struct {
ID string `json:"id,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
SpeakerID *string `json:"bgp_speaker_id"`
Name string `json:"name"`
Neighbor string `json:"neighbor"`
RemoteASN int64 `json:"remote_asn"`
Enabled bool `json:"enabled"`
SessionState string `json:"session_state"`
PoliciesJSON string `json:"policies_json"`
ID string `json:"id,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
SpeakerID *string `json:"bgp_speaker_id"`
Name string `json:"name"`
Neighbor string `json:"neighbor"`
RemoteASN int64 `json:"remote_asn"`
Enabled bool `json:"enabled"`
SessionState string `json:"session_state"`
PoliciesJSON string `json:"policies_json"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
}
type Speaker struct {
+2
View File
@@ -32,6 +32,7 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
DohProfileIDs: append([]string(nil), in.DohProfileIDs...),
DohResolverPolicy: in.DohResolverPolicy,
LastRefreshedAt: in.LastRefreshedAt,
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
}
NormalizeModuleDoh(mod)
m.modules[id] = mod
@@ -680,6 +681,7 @@ func (m *Memory) CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error) {
Neighbor: neighbor, RemoteASN: in.RemoteASN,
Enabled: EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState),
SessionState: in.SessionState, PoliciesJSON: in.PoliciesJSON,
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
}
m.peers[id] = p
return p, nil
+29 -17
View File
@@ -49,14 +49,15 @@ func (m *Memory) CreateFirewallClient(tenantID string, in *FirewallClientCreate)
id := uuid.NewString()
rec := &firewallClientRec{
FirewallClient: FirewallClient{
ID: id,
TenantID: tenantID,
Name: strings.TrimSpace(in.Name),
Hostname: strings.TrimSpace(in.Hostname),
TokenPrefix: in.TokenPrefix,
Status: "pending",
ClientVersion: strings.TrimSpace(in.ClientVersion),
CreatedAt: now,
ID: id,
TenantID: tenantID,
Name: strings.TrimSpace(in.Name),
Hostname: strings.TrimSpace(in.Hostname),
TokenPrefix: in.TokenPrefix,
Status: "pending",
ClientVersion: strings.TrimSpace(in.ClientVersion),
CreatedAt: now,
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
},
TokenHash: append([]byte(nil), in.TokenHash...),
}
@@ -311,20 +312,31 @@ func (m *Memory) CreateFirewallRule(tenantID string, clientID *string, in *Firew
now := time.Now().UTC()
id := uuid.NewString()
rule := &FirewallRule{
ID: id,
TenantID: tenantID,
ClientID: clientID,
Priority: priority,
Action: strings.ToLower(strings.TrimSpace(in.Action)),
CommunityID: in.CommunityID,
Comment: strings.TrimSpace(in.Comment),
CreatedAt: now,
UpdatedAt: now,
ID: id,
TenantID: tenantID,
ClientID: clientID,
Priority: priority,
Action: strings.ToLower(strings.TrimSpace(in.Action)),
CommunityID: in.CommunityID,
Comment: strings.TrimSpace(in.Comment),
CreatedAt: now,
UpdatedAt: now,
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
}
m.firewallRules[id] = rule
return firewallRuleCopy(rule), nil
}
func (m *Memory) GetFirewallRule(tenantID, ruleID string) (*FirewallRule, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rule, ok := m.firewallRules[ruleID]
if !ok || rule.TenantID != tenantID {
return nil, ErrNotFound
}
return firewallRuleCopy(rule), nil
}
func (m *Memory) UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error) {
if patch == nil {
return nil, ErrInvalidInput
+37
View File
@@ -0,0 +1,37 @@
package store
// Ownership helpers for portal JWT resource scoping.
// SeesAllOwned is true for API keys and portal admins (no per-user filter).
func SeesAllOwned(kind string, isAdmin bool) bool {
if kind != "jwt" {
return true
}
return isAdmin
}
// CanAccessOwned reports whether the actor may see/edit a resource with createdBy.
// Empty createdBy (legacy/API-key-created) is visible only when SeesAllOwned.
func CanAccessOwned(kind string, isAdmin bool, userID, createdBy string) bool {
if SeesAllOwned(kind, isAdmin) {
return true
}
if createdBy == "" {
return false
}
return createdBy == userID
}
// FilterOwnedStrings keeps items whose owner matches the actor.
func FilterOwned[T any](items []T, owner func(T) string, kind string, isAdmin bool, userID string) []T {
if SeesAllOwned(kind, isAdmin) {
return items
}
out := make([]T, 0, len(items))
for _, it := range items {
if CanAccessOwned(kind, isAdmin, userID, owner(it)) {
out = append(out, it)
}
}
return out
}
+67
View File
@@ -0,0 +1,67 @@
package store
import "testing"
func TestSeesAllOwned(t *testing.T) {
if !SeesAllOwned("apikey", false) {
t.Fatal("api keys must see all rows")
}
if !SeesAllOwned("jwt", true) {
t.Fatal("admin jwt must see all rows")
}
if SeesAllOwned("jwt", false) {
t.Fatal("non-admin jwt must not see all rows")
}
}
func TestCanAccessOwned(t *testing.T) {
if !CanAccessOwned("apikey", false, "", "someone") {
t.Fatal("api key must access any owner")
}
if !CanAccessOwned("jwt", true, "admin", "user-1") {
t.Fatal("admin jwt must access any owner")
}
if !CanAccessOwned("jwt", false, "user-1", "user-1") {
t.Fatal("owner must access their resource")
}
if CanAccessOwned("jwt", false, "user-1", "user-2") {
t.Fatal("non-owner must not access foreign resource")
}
if CanAccessOwned("jwt", false, "user-1", "") {
t.Fatal("non-admin jwt must not see legacy rows without owner")
}
}
type ownRow struct {
id string
owner string
}
func TestFilterOwned(t *testing.T) {
rows := []ownRow{
{"a", "user-1"},
{"b", "user-2"},
{"c", ""},
}
get := func(r ownRow) string { return r.owner }
got := FilterOwned(rows, get, "apikey", false, "")
if len(got) != 3 {
t.Fatalf("apikey filter: got=%d want 3", len(got))
}
got = FilterOwned(rows, get, "jwt", true, "any")
if len(got) != 3 {
t.Fatalf("admin jwt filter: got=%d want 3", len(got))
}
got = FilterOwned(rows, get, "jwt", false, "user-1")
if len(got) != 1 || got[0].id != "a" {
t.Fatalf("user-1 filter: got=%+v want [a]", got)
}
got = FilterOwned(rows, get, "jwt", false, "user-3")
if len(got) != 0 {
t.Fatalf("unknown user filter: got=%+v want []", got)
}
}
@@ -0,0 +1,9 @@
DROP INDEX IF EXISTS idx_firewall_rule_created_by;
DROP INDEX IF EXISTS idx_firewall_client_created_by;
DROP INDEX IF EXISTS idx_bgp_peer_created_by;
DROP INDEX IF EXISTS idx_module_created_by;
ALTER TABLE firewall_rule DROP COLUMN IF EXISTS created_by_user_id;
ALTER TABLE firewall_client DROP COLUMN IF EXISTS created_by_user_id;
ALTER TABLE bgp_peer DROP COLUMN IF EXISTS created_by_user_id;
ALTER TABLE module DROP COLUMN IF EXISTS created_by_user_id;
@@ -0,0 +1,10 @@
-- Ownership for portal JWT users (modules, peers, firewall).
ALTER TABLE module ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
ALTER TABLE bgp_peer ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
ALTER TABLE firewall_client ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
ALTER TABLE firewall_rule ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
CREATE INDEX IF NOT EXISTS idx_module_created_by ON module (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_bgp_peer_created_by ON bgp_peer (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_firewall_client_created_by ON firewall_client (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_firewall_rule_created_by ON firewall_rule (tenant_id, created_by_user_id);
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_firewall_rule_created_by;
DROP INDEX IF EXISTS idx_firewall_client_created_by;
DROP INDEX IF EXISTS idx_bgp_peer_created_by;
DROP INDEX IF EXISTS idx_module_created_by;
-- SQLite: recreate tables without column is heavy; leave columns (no-op down for v1).
-- Down migration intentionally empty for SQLite ALTER DROP COLUMN compatibility.
@@ -0,0 +1,10 @@
-- Ownership for portal JWT users (modules, peers, firewall).
ALTER TABLE module ADD COLUMN created_by_user_id TEXT;
ALTER TABLE bgp_peer ADD COLUMN created_by_user_id TEXT;
ALTER TABLE firewall_client ADD COLUMN created_by_user_id TEXT;
ALTER TABLE firewall_rule ADD COLUMN created_by_user_id TEXT;
CREATE INDEX IF NOT EXISTS idx_module_created_by ON module (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_bgp_peer_created_by ON bgp_peer (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_firewall_client_created_by ON firewall_client (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_firewall_rule_created_by ON firewall_rule (tenant_id, created_by_user_id);
+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"
+1
View File
@@ -14,6 +14,7 @@ Thumbs.db
# Env
.env
.env.local
.env.*
!.env.example
!.env.test