Compare commits

...
8 Commits
Author SHA1 Message Date
Denozordec e04fea657c feat: enhance dashboard and monitoring components with new analytics skeletons
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 1m2s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m57s
Added the AnalyticsDashboardSkeleton component to improve loading states in the dashboard and monitoring pages. Refactored the dashboard to utilize the new skeleton for initial loading, replacing the previous SectionCardsSkeleton. Updated the overview queries to increase job limit from 10 to 100 for better data handling. Enhanced the network and operations components to incorporate new analytics cards, streamlining the user experience and improving data presentation.
2026-07-09 12:09:08 +07:00
Denozordec 452f6b2db0 feat: refactor components to utilize SelectField for improved UI consistency
Updated various components to replace traditional select implementations with the new SelectField component. This change enhances the user interface by providing a more consistent layout and improved accessibility. Additionally, refactored the dashboard and operations pages to utilize DataGridCard for better organization of content, streamlining the overall user experience.
2026-07-09 11:53:43 +07:00
Denozordec b321aa5321 feat: refactor components to utilize DataGridCard for improved UI consistency
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 57s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m27s
Updated various components including AccessApiKeysCard, FirewallClientsGrid, FirewallRulesGrid, ModuleEntriesSection, and DirectoriesComponent to replace traditional card and table structures with the new DataGridCard component. This change enhances the user interface by providing a more consistent layout and improved loading states. Additionally, integrated QueryState for better handling of loading and error scenarios across these components, streamlining the overall user experience.
2026-07-09 02:31:50 +07:00
Denozordec 5d1102b497 feat(firewall): refactor FirewallPage to use new grid components and improve loading states
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 43s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m13s
Updated the FirewallPage component to replace the existing table implementations with FirewallClientsGrid and FirewallRulesGrid for better performance and user experience. Integrated QueryState for handling loading and error states, enhancing the UI responsiveness. Removed deprecated ClientsTable and RulesTable components to streamline the codebase.
2026-07-09 01:41:09 +07:00
Denozordec 5edbd656ba feat(firewall): add packet statistics tracking for firewall clients
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 29s
CI / web (push) Successful in 1m6s
CI / go (push) Successful in 1m23s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m42s
Enhanced the firewall client functionality by introducing packet statistics tracking, including the cumulative count of packets dropped and accepted. Updated the API to support these new fields and modified the database schema accordingly. Improved the firewall scripts to collect and report packet statistics, ensuring better visibility into client performance. Adjusted the UI components to display packet counts in the clients table, enhancing user experience and monitoring capabilities.
2026-07-09 00:50:38 +07:00
Denozordec 4a4c11c6bf feat(firewall): rename revoke function to delete and update related UI components
Refactored the revoke functionality for firewall clients to be more accurately represented as a delete operation. Updated the corresponding API call to use the DELETE method and modified the UI components to reflect this change, including confirmation dialogs and success messages. Adjusted tests to ensure the new delete functionality works as intended.
2026-07-09 00:27:50 +07:00
Denozordec e51999c908 feat(firewall): add revoke functionality for firewall clients and enhance status badge
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 30s
CI / web (push) Successful in 56s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 27s
CI / release (push) Successful in 4m19s
Implemented the ability to revoke approved firewall clients and reject pending requests through new API endpoints. Updated the StatusBadge component to include additional status variants for 'approved', 'revoked', 'pending', and 'block'. Enhanced the FirewallPage UI to support client revocation and rejection actions, integrating confirmation dialogs for user interactions. Updated tests to ensure proper functionality of the new revoke feature.
2026-07-08 23:27:29 +07:00
Denozordec b7f7669685 feat(firewall): improve blocklist parsing and nft element addition
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 42s
CI / go (push) Successful in 1m1s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m17s
Enhanced the blocklist parsing function to log when the blocklist file is empty. Introduced new helper functions `nft_join_elements` and `nft_add_v4_chunk` to streamline the addition of elements to the nftables, allowing for batch processing and improved error handling. Adjusted the chunk size for element addition to optimize performance. Updated logging to provide better visibility into the blocklist processing and applied prefixes.
2026-07-08 22:02:59 +07:00
87 changed files with 4957 additions and 1855 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 39884,
"pid": 43636,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783489774882
"startedAt": 1783570299043
}
@@ -1,25 +1,14 @@
import { useState } from 'react'
import { Plus, RefreshCw, Trash2 } from 'lucide-react'
import { Plus, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
import { DataGridCard } from '@/components/data-grid-shell'
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
import { Badge } from '@/components/reui/badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state'
import { StatusBadge } from '@/components/status-badge'
import { TableSkeleton } from '@/components/skeletons'
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKey, ApiKeyCreated } from '@/types/api'
@@ -58,121 +47,45 @@ export function AccessApiKeysCard({
return (
<>
<Card>
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
<DataGridCard
title="API-ключи"
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
actions={
<>
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
Обновить
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет ключей"
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
skeleton={<TableSkeleton rows={4} cols={6} />}
onRetry={onRetry}
>
{(data) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Префикс</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Истекает</TableHead>
<TableHead>Последнее использование</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((k) => (
<TableRow key={k.id}>
<TableCell className="font-medium">{k.name}</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{k.role}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{k.prefix}
</TableCell>
<TableCell>
{k.revoked_at ? (
<StatusBadge status="error" label="отозван" />
) : (
<StatusBadge status="active" label="активен" />
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatApiKeyDate(k.expires_at)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatApiKeyDate(k.last_used_at)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at || rotate.isPending}
>
<RefreshCw className="size-3.5" />
</Button>
}
title="Ротировать ключ?"
description="Старый токен перестанет работать сразу."
confirmLabel="Ротировать"
onConfirm={() => handleRotated(k.id)}
/>
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
disabled={!!k.revoked_at || revoke.isPending}
title="Отозвать"
>
<Trash2 className="size-3.5" />
</Button>
}
title="Отозвать API-ключ?"
description={`${k.name} (${k.prefix}…)`}
confirmLabel="Отозвать"
destructive
onConfirm={() => revoke.mutate(k.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
</>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет ключей"
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
skeleton={<TableSkeleton rows={4} cols={6} />}
onRetry={onRetry}
>
{(data) => (
<AccessApiKeysGrid
items={data}
isLoading={isLoading}
onRotate={handleRotated}
onRevoke={(id) => revoke.mutate(id)}
rotatePending={rotate.isPending}
revokePending={revoke.isPending}
/>
)}
</QueryState>
</DataGridCard>
<ApiKeyCreateDialog
open={createOpen}
@@ -0,0 +1,158 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { RefreshCw, Trash2 } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell'
import { Badge } from '@/components/reui/badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { ApiKey } from '@/types/api'
export function AccessApiKeysGrid({
items,
isLoading = false,
onRotate,
onRevoke,
rotatePending = false,
revokePending = false,
}: {
items: ApiKey[]
isLoading?: boolean
onRotate: (id: string) => void
onRevoke: (id: string) => void
rotatePending?: boolean
revokePending?: boolean
}) {
const columns = useMemo<ColumnDef<ApiKey>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => (
<Badge variant="outline" className="font-mono text-xs">
{row.original.role}
</Badge>
),
meta: { headerTitle: 'Роль' },
},
{
accessorKey: 'prefix',
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}</span>
),
meta: { headerTitle: 'Префикс' },
},
{
id: 'status',
enableSorting: false,
header: 'Статус',
cell: ({ row }) =>
row.original.revoked_at ? (
<StatusBadge status="error" label="отозван" />
) : (
<StatusBadge status="active" label="активен" />
),
meta: { headerTitle: 'Статус' },
},
{
id: 'expires_at',
accessorFn: (row) => row.expires_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Истекает" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatApiKeyDate(row.original.expires_at)}
</span>
),
meta: { headerTitle: 'Истекает' },
},
{
id: 'last_used_at',
accessorFn: (row) => row.last_used_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatApiKeyDate(row.original.last_used_at)}
</span>
),
meta: { headerTitle: 'Последнее использование' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const k = row.original
return (
<div className="flex gap-1">
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
type="button"
title="Ротировать"
disabled={!!k.revoked_at || rotatePending}
>
<RefreshCw className="size-3.5" />
</Button>
}
title="Ротировать ключ?"
description="Старый токен перестанет работать сразу."
confirmLabel="Ротировать"
onConfirm={() => onRotate(k.id)}
/>
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
disabled={!!k.revoked_at || revokePending}
title="Отозвать"
>
<Trash2 className="size-3.5" />
</Button>
}
title="Отозвать API-ключ?"
description={`${k.name} (${k.prefix}…)`}
confirmLabel="Отозвать"
destructive
onConfirm={() => onRevoke(k.id)}
/>
</div>
)
},
},
],
[onRevoke, onRotate, revokePending, rotatePending],
)
const table = useReactTable({
data: items,
columns,
...createClientDataGridOptions<ApiKey>(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={items.length}
isLoading={isLoading}
emptyMessage="Нет ключей"
/>
)
}
@@ -11,15 +11,9 @@ import {
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
import { useCreateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
@@ -89,25 +83,14 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCrea
placeholder="CI / оператор UI"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key-role">Роль</Label>
<Select
items={[...API_KEY_ROLE_ITEMS]}
value={role}
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
>
<SelectTrigger id="key-role" className="w-full">
<SelectValue placeholder="Выберите роль" />
</SelectTrigger>
<SelectContent>
{API_KEY_ROLE_ITEMS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<SelectField
id="key-role"
label="Роль"
items={[...API_KEY_ROLE_ITEMS]}
value={role}
placeholder="Выберите роль"
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="key-expires">Истекает (опционально)</Label>
<Input
@@ -0,0 +1,54 @@
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
import { StatusBadge } from '@/components/status-badge'
import type { PlatformActivityItem } from '@/lib/metrics'
const KIND_ICON = {
job: Info,
revision: CheckCircle,
network: AlertTriangle,
} as const
const KIND_ICON_CLASS = {
job: 'text-info',
revision: 'text-success',
network: 'text-warning',
} as const
export function AnalyticsActivityList({
items,
className,
}: {
items: PlatformActivityItem[]
className?: string
}) {
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Нет недавних событий</p>
}
return (
<ul className={cn('space-y-3', className)}>
{items.map((item) => {
const Icon = KIND_ICON[item.kind]
return (
<li key={item.id} className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<span
className={cn(
'mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted/60',
KIND_ICON_CLASS[item.kind],
)}
>
<Icon className="size-3.5" />
</span>
<p className="text-sm leading-snug">{item.message}</p>
</div>
<StatusBadge status={item.status} label={item.statusLabel} />
</li>
)
})}
</ul>
)
}
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react'
import { Info } from 'lucide-react'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
import { cn } from '@evobgp/ui/lib/utils'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@evobgp/ui/components/tooltip'
export function AnalyticsCardShell({
title,
description,
info,
actions,
footer,
className,
children,
}: {
title: string
description?: string
info?: string
actions?: ReactNode
footer?: ReactNode
className?: string
children: ReactNode
}) {
return (
<Card className={cn('flex h-full flex-col gap-0 overflow-hidden', className)}>
<CardHeader className="flex flex-row items-start justify-between gap-3 border-b py-4">
<div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
{title}
{info ? (
<Tooltip>
<TooltipTrigger
className="inline-flex text-muted-foreground transition-colors hover:text-foreground"
aria-label="Подробнее"
>
<Info className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
{info}
</TooltipContent>
</Tooltip>
) : null}
</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-5 p-5">{children}</CardContent>
{footer ? <CardFooter className="gap-2 border-t p-4">{footer}</CardFooter> : null}
</Card>
)
}
@@ -0,0 +1,50 @@
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
export type AnalyticsKpiItem = {
label: string
value: string
delta?: {
direction: 'up' | 'down' | 'neutral'
label: string
tone?: 'success' | 'warning' | 'destructive' | 'muted'
}
}
const TONE_CLASS = {
success: 'text-success',
warning: 'text-warning',
destructive: 'text-destructive',
muted: 'text-muted-foreground',
} as const
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
if (direction === 'up') return <TrendingUp className="size-3" />
if (direction === 'down') return <TrendingDown className="size-3" />
return <Minus className="size-3" />
}
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
return (
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
{items.map((item) => (
<div key={item.label} className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
{item.delta ? (
<p
className={cn(
'flex items-center gap-1 text-xs',
TONE_CLASS[item.delta.tone ?? 'muted'],
)}
>
<DeltaIcon direction={item.delta.direction} />
{item.delta.label}
</p>
) : null}
</div>
))}
</div>
)
}
@@ -0,0 +1,31 @@
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from '@evobgp/ui/components/progress'
import { cn } from '@evobgp/ui/lib/utils'
export function AnalyticsProgress({
label,
value,
className,
}: {
label: string
value: number
className?: string
}) {
const clamped = Math.max(0, Math.min(100, value))
return (
<div className={cn('space-y-2', className)}>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{clamped}%</span>
</div>
<Progress value={clamped} className="gap-0">
<ProgressTrack className="h-2">
<ProgressIndicator className="bg-foreground" />
</ProgressTrack>
</Progress>
</div>
)
}
@@ -0,0 +1,35 @@
import { Button } from '@evobgp/ui/components/button'
import { ButtonGroup } from '@evobgp/ui/components/button-group'
import { cn } from '@evobgp/ui/lib/utils'
export function AnalyticsSegmentControl<T extends string>({
value,
onChange,
options,
className,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string }[]
className?: string
}) {
return (
<ButtonGroup className={cn('rounded-lg bg-muted/50 p-0.5', className)}>
{options.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={value === option.value ? 'secondary' : 'ghost'}
className={cn(
'h-7 rounded-md px-2.5 text-xs',
value === option.value && 'bg-background shadow-sm',
)}
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
))}
</ButtonGroup>
)
}
@@ -0,0 +1,41 @@
import { Bar, BarChart, XAxis } from 'recharts'
import { ChartContainer, type ChartConfig } from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils'
import type { CapacityBar } from '@/lib/metrics'
const chartConfig = {
value: { label: 'Загрузка', color: 'var(--color-chart-2)' },
} satisfies ChartConfig
export function ChartBarStrip({
bars,
className,
}: {
bars: CapacityBar[]
className?: string
}) {
if (bars.length === 0) {
return (
<div className={cn('flex h-36 items-center justify-center text-sm text-muted-foreground', className)}>
Нет данных для графика
</div>
)
}
const data = bars.map((bar, index) => ({
...bar,
slot: index + 1,
fill: bar.value >= 80 ? 'var(--color-chart-2)' : 'var(--color-chart-3)',
}))
return (
<ChartContainer config={chartConfig} className={cn('aspect-auto h-36 w-full', className)}>
<BarChart data={data} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
<XAxis dataKey="slot" hide />
<Bar dataKey="value" radius={[3, 3, 0, 0]} maxBarSize={10} />
</BarChart>
</ChartContainer>
)
}
@@ -0,0 +1,100 @@
import { Cell, Label, Pie, PieChart } from 'recharts'
import {
ChartContainer,
type ChartConfig,
} from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils'
import type { BreakdownSlice } from '@/lib/metrics'
export function ChartDonutMetric({
slices,
centerLabel,
centerValue,
className,
}: {
slices: BreakdownSlice[]
centerLabel: string
centerValue: string | number
className?: string
}) {
const chartConfig = slices.reduce<ChartConfig>((acc, slice) => {
acc[slice.key] = { label: slice.label, color: slice.color }
return acc
}, {})
const data = slices.map((slice) => ({
...slice,
fill: slice.color,
}))
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
if (total === 0) {
return (
<div className={cn('flex h-48 items-center justify-center text-sm text-muted-foreground', className)}>
Нет данных
</div>
)
}
return (
<div className={cn('flex items-center gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart>
<Pie
data={data}
dataKey="count"
nameKey="label"
innerRadius={52}
outerRadius={72}
strokeWidth={2}
stroke="var(--color-card)"
>
{data.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
const { cx, cy } = viewBox
return (
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={cx} y={(cy ?? 0) - 6} className="fill-muted-foreground text-xs">
{centerLabel}
</tspan>
<tspan x={cx} y={(cy ?? 0) + 14} className="fill-foreground text-xl font-semibold">
{centerValue}
</tspan>
</text>
)
}}
/>
</Pie>
</PieChart>
</ChartContainer>
<ul className="min-w-0 flex-1 space-y-3">
{slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
<div className="flex min-w-0 items-center gap-2">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: slice.color }}
/>
<span className="truncate text-muted-foreground">{slice.label}</span>
</div>
<div className="shrink-0 text-right tabular-nums">
<span className="font-semibold">{slice.count}</span>
<span className="ml-2 text-muted-foreground">{pct}%</span>
</div>
</li>
)
})}
</ul>
</div>
)
}
@@ -0,0 +1,107 @@
import { useMemo, useState } from 'react'
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
} from '@evobgp/ui/components/avatar'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { ChartBarStrip } from '@/components/analytics/chart-bar-strip'
import {
capacityUtilization,
peerCapacityBars,
speakerCapacityBars,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
type CapacityMode = 'peers' | 'speakers'
function speakerInitials(speaker: SpeakerRow): string {
const label = speaker.live?.label ?? speaker.agent_domain ?? speaker.endpoint ?? speaker.id
const parts = label.split(/[.\-_@/]/).filter(Boolean)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return label.slice(0, 2).toUpperCase()
}
export function DashboardNetworkCapacityCard({
peers,
speakers,
jobs,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
const [mode, setMode] = useState<CapacityMode>('peers')
const bars = useMemo(
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
[mode, peers, speakers],
)
const utilization = capacityUtilization(bars)
const queued = runningJobCount(jobs)
const previewSpeakers = speakers.slice(0, 3)
const deltaLabel =
mode === 'peers'
? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} Established`
: `${speakers.filter((s) => s.live?.agent_ok).length} online`
return (
<AnalyticsCardShell
title="Загрузка BGP"
description="Текущая утилизация сессий по пирам и спикерам"
info="Каждый столбец — enabled peer или speaker. Высота отражает Established/online."
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'peers', label: 'Пиры' },
{ value: 'speakers', label: 'Спикеры' },
]}
/>
}
>
<div className="space-y-1">
<p className="text-3xl font-semibold tracking-tight tabular-nums">
{loading ? '—' : `${utilization}%`}
</p>
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
</div>
{loading ? (
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartBarStrip bars={bars} />
)}
<div className="flex items-center justify-between gap-3 text-sm">
<p className="text-muted-foreground">
Активных задач: <span className="font-medium text-foreground">{loading ? '—' : queued}</span>
</p>
<div className="flex items-center gap-2">
<AvatarGroup>
{previewSpeakers.map((speaker) => (
<Avatar key={speaker.id} size="sm">
<AvatarFallback>{speakerInitials(speaker)}</AvatarFallback>
</Avatar>
))}
{speakers.length > 3 ? (
<AvatarGroupCount>+{speakers.length - 3}</AvatarGroupCount>
) : null}
</AvatarGroup>
<span className="text-muted-foreground">{speakers.length} спикеров</span>
</div>
</div>
</AnalyticsCardShell>
)
}
@@ -0,0 +1,59 @@
import { useMemo, useState } from 'react'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
import type { JobRow, ModuleRow } from '@/types/api'
type FlowMode = 'jobs' | 'modules'
export function DashboardOperationsFlowCard({
jobs,
modules,
loading,
}: {
jobs: JobRow[]
modules: ModuleRow[]
loading?: boolean
}) {
const [mode, setMode] = useState<FlowMode>('jobs')
const slices = useMemo(
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
[mode, jobs, modules],
)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
return (
<AnalyticsCardShell
title="Поток операций"
description="Распределение фоновых задач и типов модулей"
info="Donut строится по текущей выборке API (до 100 последних задач)."
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'jobs', label: 'Задачи' },
{ value: 'modules', label: 'Модули' },
]}
/>
}
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric
slices={slices}
centerLabel={centerLabel}
centerValue={total}
/>
)}
</AnalyticsCardShell>
)
}
@@ -0,0 +1,141 @@
import { useNavigate } from '@tanstack/react-router'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { AnalyticsActivityList } from '@/components/analytics/analytics-activity-list'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { AnalyticsProgress } from '@/components/analytics/analytics-progress'
import {
deploymentProgress,
recentPlatformActivity,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
export function DashboardPlatformCard({
modules,
peers,
speakers,
jobs,
revisions,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
revisions: RevisionRow[]
loading?: boolean
}) {
const navigate = useNavigate()
const enabledModules = modules.filter((m) => m.enabled !== false).length
const peersEnabled = peers.filter((p) => p.enabled !== false).length
const peersEstablished = peers.filter(
(p) => p.enabled !== false && p.session_state === 'Established',
).length
const peersMismatch = peers.filter((p) => p.session_mismatch).length
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const running = runningJobCount(jobs)
const riskCount = peersMismatch + failedJobs + Math.max(0, speakers.length - speakersOnline)
const bgpPct =
peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null
const deploy = useMemo(() => deploymentProgress(speakers), [speakers])
const activity = useMemo(
() => recentPlatformActivity(jobs, revisions, peers, speakers),
[jobs, revisions, peers, speakers],
)
const kpis = [
{
label: 'Модули активны',
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
delta: {
direction: 'neutral' as const,
label: `${modules.length} всего`,
tone: 'muted' as const,
},
},
{
label: 'BGP готовность',
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
delta: {
direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as
| 'up'
| 'down'
| 'neutral',
label:
bgpPct === null
? 'нет включённых пиров'
: `${peersEstablished} Established`,
tone: (bgpPct !== null && bgpPct >= 90
? 'success'
: bgpPct !== null && bgpPct < 70
? 'warning'
: 'muted') as 'success' | 'warning' | 'muted',
},
},
{
label: 'Риски',
value: loading ? '—' : String(riskCount),
delta: {
direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down',
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} mismatch` : 'в норме',
tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success',
},
},
]
const progressLabel =
deploy.mode === 'revision'
? `Синхронизация ревизий (${deploy.synced}/${deploy.total})`
: `Спикеры online (${deploy.synced}/${deploy.total})`
return (
<AnalyticsCardShell
title="Состояние платформы"
description="Сводка модулей, BGP и фоновых задач"
info="Актуальный снимок без исторических трендов. Обновите данные кнопкой «Обновить» на странице."
footer={
<>
<Button
variant="outline"
className="flex-1"
onClick={() => navigate({ to: '/schedule' })}
>
Расписание
</Button>
<Button
className="flex-1"
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
>
Мониторинг
</Button>
</>
}
>
<AnalyticsKpiRow items={kpis} />
<AnalyticsProgress label={progressLabel} value={loading ? 0 : deploy.percent} />
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Недавняя активность</span>
{!loading ? (
<span className="text-xs text-muted-foreground">{running} активных задач</span>
) : null}
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Загрузка</p>
) : (
<AnalyticsActivityList items={activity} />
)}
</div>
</AnalyticsCardShell>
)
}
@@ -0,0 +1,13 @@
export { AnalyticsActivityList } from './analytics-activity-list'
export { AnalyticsCardShell } from './analytics-card-shell'
export { AnalyticsKpiRow, type AnalyticsKpiItem } from './analytics-kpi-row'
export { AnalyticsProgress } from './analytics-progress'
export { AnalyticsSegmentControl } from './analytics-segment-control'
export { ChartBarStrip } from './chart-bar-strip'
export { ChartDonutMetric } from './chart-donut-metric'
export { DashboardNetworkCapacityCard } from './dashboard-network-capacity-card'
export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
export { DashboardPlatformCard } from './dashboard-platform-card'
export { MonitoringHealthCard } from './monitoring-health-card'
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
export { OperationsAnalyticsCard } from './operations-analytics-card'
@@ -0,0 +1,33 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { readinessBreakdown } from '@/lib/metrics'
import type { ReadyStatus } from '@/queries/monitoring'
export function MonitoringHealthCard({
healthOk,
ready,
loading,
}: {
healthOk: boolean
ready: ReadyStatus | null | undefined
loading?: boolean
}) {
const slices = readinessBreakdown(ready, healthOk)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Доступность системы"
description="Health и readiness checks"
info="Donut отражает результат GET /v1/health и checks из GET /v1/ready."
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Checks" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
@@ -0,0 +1,66 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { peerSessionBreakdown } from '@/lib/metrics'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { PeerRow, SpeakerRow } from '@/types/api'
export function NetworkOverviewAnalyticsCard({
peers,
speakers,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
loading?: boolean
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const slices = peerSessionBreakdown(peers)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Сводка BGP"
description="Established, online и mismatch по live-данным"
info="Снимок текущего состояния пиров и спикеров."
>
<AnalyticsKpiRow
items={[
{
label: 'Пиры Established',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
delta: {
direction: net.peersMismatch > 0 ? 'down' : 'up',
label: net.peersMismatch > 0 ? `${net.peersMismatch} mismatch` : 'сессии в норме',
tone: net.peersMismatch > 0 ? 'warning' : 'success',
},
},
{
label: 'Спикеры online',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
delta: {
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
label:
net.speakersOnline < net.speakersTotal
? `${net.speakersTotal - net.speakersOnline} offline`
: 'все online',
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
},
},
{
label: 'Пиры всего',
value: loading ? '—' : String(net.peersTotal),
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
},
]}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
@@ -0,0 +1,65 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { jobStatusBreakdown } from '@/lib/metrics'
import type { JobRow, RevisionRow } from '@/types/api'
export function OperationsAnalyticsCard({
jobs,
revisions,
loading,
}: {
jobs: JobRow[]
revisions: RevisionRow[]
loading?: boolean
}) {
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const slices = jobStatusBreakdown(jobs)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Операции и задачи"
description="Статистика ревизий и фоновых jobs"
info="Данные из GET /v1/jobs и /v1/revisions."
>
<AnalyticsKpiRow
items={[
{
label: 'Ревизий',
value: loading ? '—' : String(revisions.length),
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
},
{
label: 'Активных задач',
value: loading ? '—' : String(running),
delta: {
direction: running > 0 ? 'up' : 'neutral',
label: running > 0 ? 'выполняются' : 'очередь пуста',
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
},
},
{
label: 'С ошибкой',
value: loading ? '—' : String(failed),
delta: {
direction: failed > 0 ? 'down' : 'up',
label: failed > 0 ? 'требуют внимания' : 'в норме',
tone: failed > 0 ? 'destructive' : 'success',
},
},
]}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
@@ -0,0 +1,38 @@
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { PeerRow, SpeakerRow } from '@/types/api'
function Row({ label, value, variant = 'default' }: { label: string; value: string; variant?: 'default' | 'warning' }) {
return (
<div className="flex items-center justify-between gap-2 px-1 text-sm">
<span className="text-muted-foreground">{label}</span>
<span
className={
variant === 'warning'
? 'font-medium text-warning-foreground tabular-nums'
: 'font-medium tabular-nums'
}
>
{value}
</span>
</div>
)
}
export function DashboardNetworkPanel({
peers,
speakers,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
}) {
const m = aggregateNetworkMetrics(peers, speakers)
return (
<div className="flex flex-col gap-2 p-3">
<Row label="Пиры Established" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
<Row label="Спикеры online" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
{m.peersMismatch > 0 ? (
<Row label="Mismatches" value={String(m.peersMismatch)} variant="warning" />
) : null}
</div>
)
}
@@ -0,0 +1,41 @@
import { Link } from '@tanstack/react-router'
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { CardFooter } from '@evobgp/ui/components/card'
export function DashboardQuickActions() {
return (
<CardFooter className="flex flex-wrap gap-2 border-t-0 bg-transparent">
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
<Plus className="size-4" />
Создать модуль
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
<Tags className="size-4" />
Добавить community
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}>
<Network className="size-4" />
Сеть
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'peers' }} />}>
<Share2 className="size-4" />
Добавить пира
</Button>
<Button
variant="outline"
size="sm"
type="button"
render={<Link to="/operations" search={{ tab: 'revisions' }} />}
>
<Play className="size-4" />
Деплой (Apply)
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
<Gauge className="size-4" />
Мониторинг
</Button>
</CardFooter>
)
}
@@ -0,0 +1,74 @@
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import type { JobRow } from '@/types/api'
function StatusText({ status }: { status: string }) {
const cls =
status === 'succeeded'
? 'text-success'
: status === 'failed' || status === 'cancelled'
? 'text-destructive'
: 'text-muted-foreground'
return <span className={`text-xs font-medium ${cls}`}>{status}</span>
}
export function DashboardRecentJobsGrid({
jobs,
nameById,
isLoading = false,
}: {
jobs: JobRow[]
nameById: Map<string, string>
isLoading?: boolean
}) {
const data = useMemo(() => jobs.slice(0, 8), [jobs])
const columns = useMemo<ColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<div className="min-w-0">
<div className="truncate font-mono text-xs text-muted-foreground">{row.original.kind}</div>
{row.original.meta?.module_id ? (
<div className="truncate text-xs">
{nameById.get(String(row.original.meta.module_id)) ?? ''}
</div>
) : null}
</div>
),
meta: { headerTitle: 'Вид' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusText status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
],
[nameById],
)
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.job_id,
})
return (
<DataGridShell
table={table}
recordCount={data.length}
isLoading={isLoading}
emptyMessage="Нет задач"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
/>
)
}
@@ -0,0 +1,62 @@
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import type { RevisionRow } from '@/types/api'
export function DashboardRecentRevisionsGrid({
revisions,
isLoading = false,
}: {
revisions: RevisionRow[]
isLoading?: boolean
}) {
const data = useMemo(() => revisions.slice(0, 8), [revisions])
const columns = useMemo<ColumnDef<RevisionRow>[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<span className="truncate font-mono text-xs text-muted-foreground">
{row.original.id.slice(0, 10)}
</span>
),
meta: { headerTitle: 'ID' },
},
{
accessorKey: 'created_at',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{new Date(row.original.created_at).toLocaleString('ru-RU')}
</span>
),
meta: { headerTitle: 'Создана' },
},
],
[],
)
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={data.length}
isLoading={isLoading}
emptyMessage="Нет ревизий"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
/>
)
}
@@ -0,0 +1,77 @@
import type { ReactNode } from 'react'
import type { Table } from '@tanstack/react-table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/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 {
DATA_GRID_PAGINATION_RU,
DATA_GRID_TABLE_LAYOUT,
} from '@/lib/data-grid-defaults'
interface DataGridShellProps<TData extends object> {
table: Table<TData>
recordCount: number
isLoading?: boolean
emptyMessage?: ReactNode
showPagination?: boolean
tableLayout?: typeof DATA_GRID_TABLE_LAYOUT
className?: string
onRowClick?: (row: TData) => void
}
export function DataGridShell<TData extends object>({
table,
recordCount,
isLoading = false,
emptyMessage,
showPagination = true,
tableLayout = DATA_GRID_TABLE_LAYOUT,
className,
onRowClick,
}: DataGridShellProps<TData>) {
return (
<DataGrid
table={table}
recordCount={recordCount}
isLoading={isLoading}
emptyMessage={emptyMessage}
tableLayout={tableLayout}
className={className}
onRowClick={onRowClick}
>
<DataGridContainer>
<DataGridTable />
</DataGridContainer>
{showPagination ? <DataGridPagination {...DATA_GRID_PAGINATION_RU} /> : null}
</DataGrid>
)
}
interface DataGridCardProps {
title?: string
description?: string
actions?: ReactNode
children: ReactNode
className?: string
}
export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) {
const hasHeader = Boolean(title || description || actions)
return (
<Card className={className ?? 'gap-0'}>
{hasHeader ? (
<CardHeader className="flex flex-row items-center justify-between border-b py-3">
<div className="flex flex-col gap-0.5">
{title ? <CardTitle className="text-base">{title}</CardTitle> : null}
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</CardHeader>
) : null}
<CardContent className="p-0">{children}</CardContent>
</Card>
)
}
@@ -0,0 +1,58 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { BgpCommunity } from '@/types/api'
export function DirectoriesCommunitiesGrid({
items,
isLoading = false,
}: {
items: BgpCommunity[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
() => [
{
accessorKey: 'title',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => <span className="font-medium">{row.original.title}</span>,
meta: { headerTitle: 'Название' },
},
{
accessorKey: 'community',
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.community}</span>,
meta: { headerTitle: 'Значение' },
},
{
id: 'type',
enableSorting: false,
header: 'Тип',
cell: () => <Badge variant="outline">community</Badge>,
meta: { headerTitle: 'Тип' },
},
],
[],
)
const table = useReactTable({
data: items,
columns,
...createClientDataGridOptions<BgpCommunity>(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={items.length}
isLoading={isLoading}
emptyMessage="Нет сообществ"
/>
)
}
@@ -0,0 +1,59 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { DohProfile } from '@/types/api'
export function DirectoriesDohGrid({
items,
isLoading = false,
}: {
items: DohProfile[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<DohProfile>[]>(
() => [
{
id: 'name',
accessorFn: (row) => row.name ?? row.url,
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => <span className="font-medium">{row.original.name ?? row.original.url}</span>,
meta: { headerTitle: 'Название' },
},
{
accessorKey: 'url',
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.url}</span>,
meta: { headerTitle: 'URL' },
},
{
id: 'default',
enableSorting: false,
header: 'По умолчанию',
cell: () => <Badge variant="outline"></Badge>,
meta: { headerTitle: 'По умолчанию' },
},
],
[],
)
const table = useReactTable({
data: items,
columns,
...createClientDataGridOptions<DohProfile>(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={items.length}
isLoading={isLoading}
emptyMessage="Нет DoH профилей"
/>
)
}
@@ -0,0 +1,19 @@
import { Field } from '@evobgp/ui/components/field'
import { SelectMenu } from '@/components/select-field'
const items = [
{ label: 'Select an item', value: 'placeholder' as const },
...Array.from({ length: 100 }).map((_, i) => ({
label: `Item ${i}`,
value: `item-${i}` as const,
})),
]
export function Pattern() {
return (
<Field className="max-w-xs">
<SelectMenu items={items} placeholder="Select an item" />
</Field>
)
}
@@ -0,0 +1,191 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { FirewallClient } from '@/types/api'
function formatPacketCount(value?: number | null): string | null {
if (value == null || value <= 0) return null
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
return String(value)
}
export interface FirewallClientsGridProps {
clients: FirewallClient[]
isLoading?: boolean
onApprove: (id: string) => void
onReject: (id: string) => void
approvePending?: boolean
rejectPending?: boolean
emptyTitle?: string
}
export function FirewallClientsGrid({
clients,
isLoading = false,
onApprove,
onReject,
approvePending = false,
rejectPending = false,
emptyTitle = 'Нет клиентов',
}: FirewallClientsGridProps) {
const columns = useMemo<ColumnDef<FirewallClient>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.name}</div>
<div className="text-muted-foreground text-xs">
{row.original.hostname || row.original.token_prefix}
</div>
</div>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
{
id: 'last_seen_at',
accessorFn: (row) => row.last_seen_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Last seen" />,
cell: ({ row }) => (
<span className="text-xs">{row.original.last_seen_at?.slice(0, 19) ?? '—'}</span>
),
sortingFn: (a, b) => {
const av = a.original.last_seen_at ?? ''
const bv = b.original.last_seen_at ?? ''
return av.localeCompare(bv)
},
meta: { headerTitle: 'Last seen' },
},
{
id: 'apply',
enableSorting: false,
header: 'Apply',
cell: ({ row }) => {
const c = row.original
return (
<span className="text-xs">
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</span>
)
},
meta: { headerTitle: 'Apply' },
},
{
id: 'packets',
enableSorting: false,
header: 'Пакеты',
cell: ({ row }) => {
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
if (!dropped && !accepted) {
return <span className="text-muted-foreground text-xs"></span>
}
return (
<span className="text-muted-foreground text-xs">
{dropped ? <span className="text-destructive">{dropped}</span> : null}
{dropped && accepted ? ' · ' : null}
{accepted ? <span className="text-success">{accepted}</span> : null}
</span>
)
},
meta: { headerTitle: 'Пакеты' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const c = row.original
return (
<div className="flex justify-end gap-2">
{c.status === 'pending' ? (
<>
<Button
size="sm"
variant="outline"
type="button"
disabled={approvePending}
onClick={() => onApprove(c.id)}
>
Одобрить
</Button>
<ConfirmDialog
trigger={
<Button
size="sm"
variant="outline"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Отклонить
</Button>
}
title="Отклонить запрос?"
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
confirmLabel="Отклонить"
destructive
onConfirm={() => onReject(c.id)}
/>
</>
) : null}
{c.status === 'approved' ? (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Удалить
</Button>
}
title="Удалить клиент?"
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
confirmLabel="Удалить"
destructive
onConfirm={() => onReject(c.id)}
/>
) : null}
</div>
)
},
},
],
[approvePending, onApprove, onReject, rejectPending],
)
const table = useReactTable({
data: clients,
columns,
...createClientDataGridOptions<FirewallClient>(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={clients.length}
isLoading={isLoading}
emptyMessage={emptyTitle}
/>
)
}
@@ -0,0 +1,118 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { communityLabel } from '@/lib/modules/helpers'
import type { BgpCommunity, FirewallRule } from '@/types/api'
export interface FirewallRulesGridProps {
rules: FirewallRule[]
communities: BgpCommunity[]
isLoading?: boolean
onDelete: (id: string) => void
deletePending?: boolean
emptyTitle?: string
}
export function FirewallRulesGrid({
rules,
communities,
isLoading = false,
onDelete,
deletePending = false,
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
}: FirewallRulesGridProps) {
const columns = useMemo<ColumnDef<FirewallRule>[]>(
() => [
{
accessorKey: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
cell: ({ row }) => row.original.priority,
meta: { headerTitle: '#' },
},
{
accessorKey: 'action',
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
cell: ({ row }) => (
<StatusBadge status={row.original.action} label={row.original.action} />
),
meta: { headerTitle: 'Действие' },
},
{
id: 'community',
enableSorting: false,
header: 'Community',
cell: ({ row }) => (
<span className="text-sm">
{row.original.community_id
? communityLabel(row.original.community_id, communities)
: 'Все'}
</span>
),
meta: { headerTitle: 'Community' },
},
{
accessorKey: 'comment',
enableSorting: false,
header: 'Комментарий',
cell: ({ row }) => row.original.comment || '—',
meta: { headerTitle: 'Комментарий' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const r = row.original
return (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={deletePending}
>
Удалить
</Button>
}
title="Удалить правило?"
description={
r.comment
? `Правило #${r.priority} (${r.action}): ${r.comment}`
: `Правило #${r.priority} (${r.action}) будет удалено.`
}
confirmLabel="Удалить"
destructive
onConfirm={() => onDelete(r.id)}
/>
)
},
},
],
[communities, deletePending, onDelete],
)
const table = useReactTable({
data: rules,
columns,
...createClientDataGridOptions<FirewallRule>(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={rules.length}
isLoading={isLoading}
emptyMessage={emptyTitle}
/>
)
}
@@ -1,14 +1,8 @@
import { useMemo } from 'react'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Field, FieldLabel } from '@evobgp/ui/components/field'
import { SelectMenu } from '@/components/select-field'
import {
NONE_OPTION,
communityOptionLabel,
@@ -49,28 +43,27 @@ export function CommunitySelect({
const selectValue = nullable ? nullableSelectValue(value) : (value ?? '')
const select = (
<SelectMenu
id={id}
items={items}
value={selectValue}
placeholder={placeholder}
onValueChange={(v) => {
if (!v) return
onValueChange(nullable ? fromNullableSelect(v) : v)
}}
/>
)
if (!label) {
return select
}
return (
<div className="flex flex-col gap-1.5">
{label ? <Label htmlFor={id}>{label}</Label> : null}
<Select
items={items}
value={selectValue}
onValueChange={(v) => {
if (!v) return
onValueChange(nullable ? fromNullableSelect(v) : v)
}}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
{select}
</Field>
)
}
@@ -10,13 +10,8 @@ import {
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { SelectField } from '@/components/select-field'
import { Button } from '@evobgp/ui/components/button'
import { LoadingButton } from '@/components/loading-button'
@@ -174,25 +169,13 @@ export function ModuleCdnSourceDialog({
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-kind">Тип источника</Label>
<Select
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
>
<SelectTrigger id="cdn-kind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{kindItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<SelectField
id="cdn-kind"
label="Тип источника"
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
@@ -0,0 +1,251 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { Pencil, Trash2 } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatDateTime } from '@/lib/modules/display'
import { communityLabel } from '@/lib/modules/helpers'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type {
AsEntry,
BgpCommunity,
CdnSource,
DomainEntry,
IpRangeEntry,
ModuleRow,
} from '@/types/api'
type DeleteTarget =
| { kind: 'domain'; entry: DomainEntry }
| { kind: 'ip-range'; entry: IpRangeEntry }
| { kind: 'cdn'; entry: CdnSource }
| { kind: 'as'; entry: AsEntry }
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" type="button" onClick={onEdit} aria-label="Редактировать">
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
onClick={onDelete}
aria-label="Удалить"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
export function ModuleEntriesGrid({
mod,
rows,
communities,
onEdit,
onDelete,
isLoading = false,
}: {
mod: ModuleRow
rows: Record<string, unknown>[]
communities: BgpCommunity[]
onEdit: (target: DeleteTarget) => void
onDelete: (target: DeleteTarget) => void
isLoading?: boolean
}) {
const columns = useMemo(() => {
if (mod.type === 'DOMAINS') {
return [
{
accessorKey: 'fqdn',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="FQDN" />
),
cell: ({ row }: { row: { original: DomainEntry } }) => (
<span className="font-mono text-sm">{row.original.fqdn}</span>
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: DomainEntry } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: DomainEntry } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'domain', entry: row.original })}
onDelete={() => onDelete({ kind: 'domain', entry: row.original })}
/>
),
},
] as ColumnDef<DomainEntry>[]
}
if (mod.type === 'IP_RANGES') {
return [
{
accessorKey: 'prefix',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" />
),
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
<span className="font-mono text-sm">{row.original.prefix}</span>
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original })}
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original })}
/>
),
},
] as ColumnDef<IpRangeEntry>[]
}
if (mod.type === 'CDN_CIDRS') {
return [
{
accessorKey: 'url',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="URL" />
),
cell: ({ row }: { row: { original: CdnSource } }) => (
<span className="max-w-xs truncate font-mono text-xs">{row.original.url}</span>
),
},
{
accessorKey: 'source_kind',
header: 'Тип',
cell: ({ row }: { row: { original: CdnSource } }) => (
<span className="text-sm">{row.original.source_kind}</span>
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: CdnSource } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'last_refreshed_at',
accessorFn: (row: CdnSource) => row.last_refreshed_at ?? '',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="Обновлено" />
),
cell: ({ row }: { row: { original: CdnSource } }) => (
<span className="text-xs text-muted-foreground">
{formatDateTime(row.original.last_refreshed_at)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: CdnSource } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'cdn', entry: row.original })}
onDelete={() => onDelete({ kind: 'cdn', entry: row.original })}
/>
),
},
] as ColumnDef<CdnSource>[]
}
return [
{
accessorKey: 'asn',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="ASN" />
),
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="font-mono text-sm">{row.original.asn}</span>
),
},
{
accessorKey: 'asn_name',
header: 'Имя',
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="text-sm">{row.original.asn_name ?? '—'}</span>
),
},
{
accessorKey: 'prefix_count',
header: 'Префиксов',
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="font-mono text-sm">{row.original.prefix_count ?? '—'}</span>
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: AsEntry } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'as', entry: row.original })}
onDelete={() => onDelete({ kind: 'as', entry: row.original })}
/>
),
},
] as ColumnDef<AsEntry>[]
}, [communities, mod.type, onDelete, onEdit])
type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry
const data = rows as unknown as RowType[]
const table = useReactTable({
data,
columns: columns as ColumnDef<RowType>[],
...createClientDataGridOptions<RowType>(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={data.length}
isLoading={isLoading}
emptyMessage="Нет записей"
/>
)
}
export type { DeleteTarget as ModuleEntryDeleteTarget }
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { Plus } from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -13,25 +13,16 @@ import {
AlertDialogTitle,
} from '@evobgp/ui/components/alert-dialog'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { DataGridCard } from '@/components/data-grid-shell'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
import { ModuleDomainEntryDialog } from '@/components/modules/module-domain-entry-dialog'
import { ModuleIpRangeEntryDialog } from '@/components/modules/module-ip-range-entry-dialog'
import { ApiError, apiMutate } from '@/lib/api-client'
import { communityLabel } from '@/lib/modules/helpers'
import { formatDateTime } from '@/lib/modules/display'
import type {
AsEntry,
BgpCommunity,
@@ -53,11 +44,7 @@ interface ModuleEntriesSectionProps {
onChanged: () => void | Promise<void>
}
type DeleteTarget =
| { kind: 'domain'; entry: DomainEntry }
| { kind: 'ip-range'; entry: IpRangeEntry }
| { kind: 'cdn'; entry: CdnSource }
| { kind: 'as'; entry: AsEntry }
type DeleteTarget = ModuleEntryDeleteTarget
const CARD_META: Record<
ModuleRow['type'],
@@ -150,49 +137,45 @@ export function ModuleEntriesSection({
return (
<>
<Card>
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">{meta.title}</CardTitle>
<CardDescription>{meta.description}</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button size="sm" onClick={openCreate}>
<Plus />
Добавить
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle={meta.emptyTitle}
emptyDescription={meta.emptyDescription}
skeleton={<TableSkeleton rows={5} cols={3} />}
onRetry={onRetry}
>
{(rows) => (
<EntriesTable
mod={mod}
rows={rows}
communities={communities}
onEdit={(target) => {
if (target.kind === 'domain') setEditDomain(target.entry)
if (target.kind === 'ip-range') setEditIpRange(target.entry)
if (target.kind === 'cdn') setEditCdn(target.entry)
if (target.kind === 'as') setEditAs(target.entry)
setDialogOpen(true)
}}
onDelete={setDeleteTarget}
/>
)}
</QueryState>
</CardContent>
</Card>
<DataGridCard
title={meta.title}
description={meta.description}
actions={
<Button size="sm" type="button" onClick={openCreate}>
<Plus />
Добавить
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle={meta.emptyTitle}
emptyDescription={meta.emptyDescription}
skeleton={<TableSkeleton rows={5} cols={3} />}
onRetry={onRetry}
>
{(rows) => (
<ModuleEntriesGrid
mod={mod}
rows={rows}
communities={communities}
isLoading={isLoading}
onEdit={(target) => {
if (target.kind === 'domain') setEditDomain(target.entry)
if (target.kind === 'ip-range') setEditIpRange(target.entry)
if (target.kind === 'cdn') setEditCdn(target.entry)
if (target.kind === 'as') setEditAs(target.entry)
setDialogOpen(true)
}}
onDelete={setDeleteTarget}
/>
)}
</QueryState>
</DataGridCard>
{mod.type === 'DOMAINS' ? (
<ModuleDomainEntryDialog
@@ -270,168 +253,3 @@ function deleteDescription(target: DeleteTarget | null): string {
return `AS${target.entry.asn}`
}
}
function EntriesTable({
mod,
rows,
communities,
onEdit,
onDelete,
}: {
mod: ModuleRow
rows: Record<string, unknown>[]
communities: BgpCommunity[]
onEdit: (target: DeleteTarget) => void
onDelete: (target: DeleteTarget) => void
}) {
if (mod.type === 'DOMAINS') {
const entries = rows as unknown as DomainEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>FQDN</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.fqdn}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'domain', entry })}
onDelete={() => onDelete({ kind: 'domain', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
if (mod.type === 'IP_RANGES') {
const entries = rows as unknown as IpRangeEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Префикс (CIDR)</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.prefix}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'ip-range', entry })}
onDelete={() => onDelete({ kind: 'ip-range', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
if (mod.type === 'CDN_CIDRS') {
const entries = rows as unknown as CdnSource[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>URL</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Community</TableHead>
<TableHead>Обновлено</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="max-w-xs truncate font-mono text-xs">{entry.url}</TableCell>
<TableCell className="text-sm">{entry.source_kind}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(entry.last_refreshed_at)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'cdn', entry })}
onDelete={() => onDelete({ kind: 'cdn', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
const entries = rows as unknown as AsEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>ASN</TableHead>
<TableHead>Имя</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.asn}</TableCell>
<TableCell className="text-sm">{entry.asn_name ?? '—'}</TableCell>
<TableCell className="font-mono text-sm">{entry.prefix_count ?? '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'as', entry })}
onDelete={() => onDelete({ kind: 'as', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={onEdit} aria-label="Редактировать">
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
onClick={onDelete}
aria-label="Удалить"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
@@ -0,0 +1,99 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { useNavigate } from '@tanstack/react-router'
import { Boxes } from 'lucide-react'
import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { TruncatedText } from '@/components/truncated-text'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { ModuleRow } from '@/types/api'
export function ModulesListGrid({
items,
isLoading = false,
}: {
items: ModuleRow[]
isLoading?: boolean
}) {
const navigate = useNavigate()
const columns = useMemo<ColumnDef<ModuleRow>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Boxes className="size-4 text-muted-foreground" />
<TruncatedText className="max-w-[280px] font-medium">{row.original.name}</TruncatedText>
</div>
),
meta: { headerTitle: 'Название' },
},
{
accessorKey: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
meta: { headerTitle: 'Тип' },
},
{
accessorKey: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
),
meta: { headerTitle: 'Приоритет' },
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled ? 'enabled' : 'disabled'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) =>
row.original.enabled ? (
<Badge variant="success">включён</Badge>
) : (
<Badge variant="secondary">выключен</Badge>
),
meta: { headerTitle: 'Состояние' },
},
{
id: 'last_refreshed_at',
accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'}
</span>
),
sortingFn: (a, b) => {
const av = a.original.last_refreshed_at ?? ''
const bv = b.original.last_refreshed_at ?? ''
return av.localeCompare(bv)
},
meta: { headerTitle: 'Обновлено' },
},
],
[],
)
const table = useReactTable({
data: items,
columns,
...createClientDataGridOptions<ModuleRow>(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={items.length}
isLoading={isLoading}
emptyMessage="Нет модулей"
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
/>
)
}
@@ -0,0 +1,123 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { ReadyStatus } from '@/queries/monitoring'
const READY_CHECK_ICONS: Record<string, typeof Database> = {
postgres: Database,
store: HardDrive,
jobs: ListTodo,
}
interface ReadyCheckRow {
id: string
label: string
subtitle?: string
icon: typeof Database
ok: boolean
statusLabel: string
variant: 'default' | 'destructive' | 'secondary'
}
export function MonitoringReadyGrid({
health,
ready,
}: {
health?: { ok?: boolean; status?: string; error?: string } | null
ready: ReadyStatus
}) {
const data = useMemo<ReadyCheckRow[]>(() => {
const checks = ready.checks ?? {}
const rows: ReadyCheckRow[] = [
{
id: 'liveness',
label: 'Liveness',
subtitle: '/v1/health',
icon: HeartPulse,
ok: health?.ok === true,
statusLabel: health?.ok ? 'OK' : 'Ошибка',
variant: health?.ok ? 'default' : 'destructive',
},
{
id: 'readiness',
label: 'Readiness',
subtitle: '/v1/ready',
icon: ShieldCheck,
ok: ready.status === 'ok',
statusLabel: ready.status ?? '—',
variant: ready.status === 'ok' ? 'default' : 'secondary',
},
]
for (const key of Object.keys(checks)) {
const value = checks[key]
const ok = typeof value === 'boolean' ? value : value?.ok !== false
rows.push({
id: key,
label: key,
icon: READY_CHECK_ICONS[key] ?? ListTodo,
ok,
statusLabel: ok ? 'OK' : 'Ошибка',
variant: ok ? 'default' : 'destructive',
})
}
return rows
}, [health?.ok, ready.checks, ready.status])
const columns = useMemo<ColumnDef<ReadyCheckRow>[]>(
() => [
{
accessorKey: 'label',
header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />,
cell: ({ row }) => {
const Icon = row.original.icon
return (
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{row.original.label}</p>
{row.original.subtitle ? (
<p className="text-xs text-muted-foreground">{row.original.subtitle}</p>
) : null}
</div>
</div>
)
},
meta: { headerTitle: 'Проверка' },
},
{
id: 'status',
enableSorting: false,
header: 'Статус',
cell: ({ row }) => (
<Badge variant={row.original.variant}>{row.original.statusLabel}</Badge>
),
meta: { headerTitle: 'Статус' },
},
],
[],
)
const table = useReactTable({
data,
columns,
...createClientDataGridOptions<ReadyCheckRow>({
initialState: { pagination: { pageSize: 20 } },
}),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={data.length}
showPagination={false}
emptyMessage="Нет проверок"
/>
)
}
@@ -0,0 +1,77 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridShell } 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults'
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 }) => (
<span className="font-medium">{row.original.name ?? row.original.neighbor}</span>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Neighbor" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.neighbor}</span>,
meta: { headerTitle: 'Neighbor' },
},
{
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} />
{row.original.session_mismatch ? (
<Badge variant="warning" className="ml-1">
mismatch
</Badge>
) : null}
</div>
),
meta: { headerTitle: 'Состояние' },
},
],
[],
)
const table = useReactTable({
data: items,
columns,
...createClientDataGridOptions<PeerRow>(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={items.length}
isLoading={isLoading}
emptyMessage="Нет пиров"
/>
)
}
@@ -0,0 +1,78 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridShell } 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults'
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="Endpoint" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.endpoint}</span>,
meta: { headerTitle: 'Endpoint' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <Badge variant="outline">{row.original.role}</Badge>,
meta: { headerTitle: 'Роль' },
},
{
id: 'agent',
enableSorting: false,
header: 'Agent',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) return <StatusBadge status="ok" label="online" />
if (live?.agent_ok === false) return <StatusBadge status="error" label="offline" />
return <Badge variant="outline"></Badge>
},
meta: { headerTitle: 'Agent' },
},
{
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 = useReactTable({
data: items,
columns,
...createClientDataGridOptions<SpeakerRow>(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={items.length}
isLoading={isLoading}
emptyMessage="Нет спикеров"
/>
)
}
@@ -0,0 +1,131 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { useMutation } from '@tanstack/react-query'
import { useMemo } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { apiMutate } from '@/lib/api-client'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
function StatusBadgeColored({ status }: { status: string }) {
const cls =
status === 'succeeded'
? 'text-success'
: status === 'failed' || status === 'cancelled'
? 'text-destructive'
: 'text-info'
return <span className={`text-sm font-medium ${cls}`}>{status}</span>
}
export function OperationsJobsGrid({
items,
nameById,
qc,
isLoading = false,
}: {
items: JobRow[]
nameById: Map<string, string>
qc: QueryClient
isLoading?: boolean
}) {
const cancelMutation = useMutation({
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
onSuccess: () => {
toast.success('Задача отменена')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
})
const columns = useMemo<ColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<div className="flex flex-col gap-0.5">
<span className="font-medium">{row.original.kind}</span>
{row.original.meta?.module_id ? (
<span className="text-xs text-muted-foreground">
{nameById.get(String(row.original.meta.module_id)) ??
String(row.original.meta.module_id)}
</span>
) : null}
</div>
),
meta: { headerTitle: 'Вид' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadgeColored status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
{
id: 'created_at',
accessorFn: (row) => row.created_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<span className="whitespace-nowrap text-xs text-muted-foreground">
{row.original.created_at
? new Date(row.original.created_at).toLocaleString('ru-RU')
: '—'}
</span>
),
meta: { headerTitle: 'Создана' },
},
{
id: 'finished_at',
accessorFn: (row) => row.finished_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
cell: ({ row }) => (
<span className="whitespace-nowrap text-xs text-muted-foreground">
{row.original.finished_at
? new Date(row.original.finished_at).toLocaleString('ru-RU')
: '—'}
</span>
),
meta: { headerTitle: 'Завершена' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) =>
row.original.status === 'running' || row.original.status === 'queued' ? (
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
onClick={() => cancelMutation.mutate(row.original.job_id)}
>
</Button>
) : null,
},
],
[cancelMutation, nameById],
)
const table = useReactTable({
data: items,
columns,
...createClientDataGridOptions<JobRow>(),
getRowId: (row) => row.job_id,
})
return (
<DataGridShell
table={table}
recordCount={items.length}
isLoading={isLoading}
emptyMessage="Нет задач"
/>
)
}
@@ -0,0 +1,105 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { useMutation } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useMemo } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { apiMutate } from '@/lib/api-client'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { RevisionRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
export function OperationsRevisionsGrid({
items,
qc,
isLoading = false,
}: {
items: RevisionRow[]
qc: QueryClient
isLoading?: boolean
}) {
const rollbackMutation = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
onSuccess: () => {
toast.success('Откат выполнен')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
})
const columns = useMemo<ColumnDef<RevisionRow>[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.id.slice(0, 12)}</span>
),
meta: { headerTitle: 'ID' },
},
{
accessorKey: 'created_at',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{new Date(row.original.created_at).toLocaleString('ru-RU')}
</span>
),
meta: { headerTitle: 'Создана' },
},
{
accessorKey: 'materialized_prefix_count',
header: ({ column }) => <DataGridColumnHeader column={column} title="Префиксов" />,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">
{row.original.materialized_prefix_count}
</span>
),
meta: { headerTitle: 'Префиксов' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => (
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" type="button" className="text-destructive">
<RefreshCw className="size-3.5" />
</Button>
}
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
confirmLabel="Откатить"
destructive
onConfirm={() => rollbackMutation.mutate(row.original.id)}
/>
),
},
],
[rollbackMutation],
)
const table = useReactTable({
data: items,
columns,
...createClientDataGridOptions<RevisionRow>(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={items.length}
isLoading={isLoading}
emptyMessage="Нет ревизий"
/>
)
}
@@ -4,12 +4,8 @@ import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
SelectMenu,
} from "@/components/select-field"
import { Skeleton } from "@evobgp/ui/components/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
@@ -152,28 +148,23 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
items={mergedProps?.sizes?.map((size: number) => ({
value: `${size}`,
label: `${size}`,
}))}
<SelectMenu
items={
mergedProps?.sizes?.map((size: number) => ({
value: `${size}`,
label: `${size}`,
})) ?? []
}
value={`${pageSize}`}
triggerClassName="w-14"
size="sm"
side="top"
contentClassName="min-w-18"
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
if (!value) return
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="w-14" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
/>
</>
)}
</div>
+175
View File
@@ -0,0 +1,175 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame → all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
* :not() or !important needed
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
],
{
variants: {
variant: {
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
},
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
},
stacked: {
true: [
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
"*:has-[+[data-slot=frame-panel]]:before:hidden",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
],
false: [
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
defaultVariants: {
variant: "default",
spacing: "default",
stacked: false,
dense: false,
},
}
)
function Frame({
className,
variant,
spacing,
stacked,
dense,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
return (
<div
className={cn(
frameVariants({ variant, spacing, stacked, dense }),
className
)}
data-slot="frame"
data-spacing={spacing}
{...props}
/>
)
}
function FramePanel({
className,
fit,
...props
}: React.ComponentProps<"div"> & { fit?: boolean }) {
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
)}
data-slot="frame-panel"
{...props}
/>
)
}
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
return (
<header
className={cn(
"flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
className
)}
data-slot="frame-panel-header"
{...props}
/>
)
}
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("text-sm font-semibold", className)}
data-slot="frame-panel-title"
{...props}
/>
)
}
function FrameDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-panel-description"
{...props}
/>
)
}
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
return (
<footer
className={cn(
"flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
className
)}
data-slot="frame-panel-footer"
{...props}
/>
)
}
export {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
FrameFooter,
frameVariants,
}
@@ -0,0 +1,100 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { JobRow } from '@/types/api'
export function ScheduleJobsGrid({
items,
isLoading = false,
}: {
items: JobRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => <span className="font-medium">{row.original.kind}</span>,
meta: { headerTitle: 'Вид' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => (
<Badge
variant={
row.original.status === 'succeeded'
? 'default'
: row.original.status === 'failed'
? 'destructive'
: 'secondary'
}
>
{row.original.status}
</Badge>
),
meta: { headerTitle: 'Статус' },
},
{
id: 'created_at',
accessorFn: (row) => row.created_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<span className="whitespace-nowrap text-xs text-muted-foreground">
{row.original.created_at
? new Date(row.original.created_at).toLocaleString('ru-RU')
: '—'}
</span>
),
meta: { headerTitle: 'Создана' },
},
{
id: 'finished_at',
accessorFn: (row) => row.finished_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
cell: ({ row }) => (
<span className="whitespace-nowrap text-xs text-muted-foreground">
{row.original.finished_at
? new Date(row.original.finished_at).toLocaleString('ru-RU')
: '—'}
</span>
),
meta: { headerTitle: 'Завершена' },
},
{
accessorKey: 'error',
enableSorting: false,
header: 'Ошибка',
cell: ({ row }) => (
<span className="max-w-xs truncate text-xs text-destructive">
{row.original.error ?? ''}
</span>
),
meta: { headerTitle: 'Ошибка' },
},
],
[],
)
const table = useReactTable({
data: items,
columns,
...createClientDataGridOptions<JobRow>(),
getRowId: (row) => row.job_id,
})
return (
<DataGridShell
table={table}
recordCount={items.length}
isLoading={isLoading}
emptyMessage="Нет задач"
/>
)
}
@@ -0,0 +1,113 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { RefreshCw } from 'lucide-react'
import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge'
import { DataGridShell } from '@/components/data-grid-shell'
import { LoadingButton } from '@/components/loading-button'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { ModuleRow } from '@/types/api'
export function ScheduleModulesGrid({
items,
refreshing,
onRefresh,
isLoading = false,
}: {
items: ModuleRow[]
refreshing: Record<string, boolean>
onRefresh: (id: string) => void
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<ModuleRow>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
meta: { headerTitle: 'Модуль' },
},
{
accessorKey: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
meta: { headerTitle: 'Тип' },
},
{
id: 'schedule',
accessorFn: (row) => row.cron_expr ?? String(row.refresh_interval_sec ?? ''),
header: ({ column }) => <DataGridColumnHeader column={column} title="Расписание" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.cron_expr ??
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec}s` : '—')}
</span>
),
meta: { headerTitle: 'Расписание' },
},
{
id: 'last_refreshed_at',
accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => (
<span className="whitespace-nowrap text-xs text-muted-foreground">
{row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'}
</span>
),
meta: { headerTitle: 'Обновлено' },
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled ? 'on' : 'off'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) =>
row.original.enabled ? (
<Badge variant="default">Вкл</Badge>
) : (
<Badge variant="secondary">Выкл</Badge>
),
meta: { headerTitle: 'Статус' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => (
<div className="text-right">
<LoadingButton
size="sm"
variant="secondary"
type="button"
loading={!!refreshing[row.original.id]}
onClick={() => onRefresh(row.original.id)}
>
<RefreshCw />
Обновить
</LoadingButton>
</div>
),
},
],
[onRefresh, refreshing],
)
const table = useReactTable({
data: items,
columns,
...createClientDataGridOptions<ModuleRow>(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={items.length}
isLoading={isLoading}
emptyMessage="Нет модулей"
/>
)
}
+100
View File
@@ -0,0 +1,100 @@
import type { ComponentProps, ReactNode } from 'react'
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { cn } from '@evobgp/ui/lib/utils'
export type SelectMenuItem<T extends string = string> = {
value: T
label: ReactNode
disabled?: boolean
}
type SelectRootProps = ComponentProps<typeof Select>
export interface SelectMenuProps<T extends string = string>
extends Omit<SelectRootProps, 'children' | 'onValueChange' | 'value' | 'defaultValue'> {
items: ReadonlyArray<SelectMenuItem<T>>
value?: T | null
defaultValue?: T | null
onValueChange?: (value: T | null) => void
placeholder?: string
id?: string
triggerClassName?: string
contentClassName?: string
size?: 'sm' | 'default'
side?: ComponentProps<typeof SelectContent>['side']
}
/** ReUI c-select-4: `items` на Root, опции в `SelectGroup`. */
export function SelectMenu<T extends string = string>({
items,
placeholder,
id,
triggerClassName,
contentClassName,
size = 'default',
side,
value,
defaultValue,
onValueChange,
...selectProps
}: SelectMenuProps<T>) {
return (
<Select
items={items}
value={value}
defaultValue={defaultValue}
onValueChange={(next) => onValueChange?.(next as T | null)}
{...selectProps}
>
<SelectTrigger id={id} className={cn('w-full', triggerClassName)} size={size}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent side={side} className={contentClassName}>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value} disabled={item.disabled}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
export interface SelectFieldProps<T extends string = string> extends SelectMenuProps<T> {
label?: ReactNode
description?: ReactNode
fieldClassName?: string
}
export function SelectField<T extends string = string>({
label,
description,
fieldClassName,
id,
...menuProps
}: SelectFieldProps<T>) {
return (
<Field className={fieldClassName}>
{label ? <FieldLabel htmlFor={id}>{label}</FieldLabel> : null}
<SelectMenu id={id} {...menuProps} />
{description ? (
typeof description === 'string' ? (
<FieldDescription className="font-mono text-xs">{description}</FieldDescription>
) : (
description
)
) : null}
</Field>
)
}
@@ -0,0 +1,57 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
export interface SettingsKvRow {
id: string | number
key: string
value: string
}
export function SettingsKvGrid({
items,
isLoading = false,
}: {
items: SettingsKvRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<SettingsKvRow>[]>(
() => [
{
accessorKey: 'key',
header: ({ column }) => <DataGridColumnHeader column={column} title="Ключ" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.key}</span>,
meta: { headerTitle: 'Ключ' },
},
{
accessorKey: 'value',
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.value}</span>,
meta: { headerTitle: 'Значение' },
},
],
[],
)
const table = useReactTable({
data: items,
columns,
...createClientDataGridOptions<SettingsKvRow>({
initialState: { pagination: { pageSize: 25 } },
}),
getRowId: (row) => String(row.id),
})
return (
<DataGridShell
table={table}
recordCount={items.length}
isLoading={isLoading}
emptyMessage="Нет дополнительных настроек"
showPagination={items.length > 10}
/>
)
}
+35 -2
View File
@@ -1,6 +1,7 @@
import { SectionCards } from './section-cards'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { SectionCards } from './section-cards'
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
return (
@@ -14,6 +15,38 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
)
}
export function AnalyticsDashboardSkeleton() {
return (
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
<Card className="gap-0 lg:row-span-2">
<CardContent className="space-y-4 p-5">
<Skeleton className="h-4 w-40" />
<div className="grid gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
<Skeleton className="h-2 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
<Card className="gap-0">
<CardContent className="space-y-4 p-5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-24" />
<Skeleton className="h-36 w-full" />
</CardContent>
</Card>
<Card className="gap-0">
<CardContent className="space-y-4 p-5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-44 w-full" />
</CardContent>
</Card>
</div>
)
}
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
return (
<Card className="gap-0">
+5
View File
@@ -22,6 +22,11 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
stale: 'warning',
warning: 'warning',
mismatch: 'warning',
pending: 'warning',
approved: 'success',
revoked: 'destructive',
block: 'destructive',
accept: 'success',
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
+44
View File
@@ -0,0 +1,44 @@
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type TableOptions,
} from '@tanstack/react-table'
import type { DataGridProps } from '@/components/reui/data-grid/data-grid'
export const DATA_GRID_TABLE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
dense: true,
headerSticky: true,
rowBorder: true,
}
export const DATA_GRID_PAGINATION_RU = {
sizes: [10, 25, 50] as number[],
sizesLabel: 'Показать',
sizesDescription: 'на странице',
info: '{from}{to} из {count}',
rowsPerPageLabel: 'Строк на странице',
previousPageLabel: 'Предыдущая страница',
nextPageLabel: 'Следующая страница',
}
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
...DATA_GRID_TABLE_LAYOUT,
dense: true,
}
export function createClientDataGridOptions<TData extends object>(
overrides?: Partial<TableOptions<TData>>,
): Pick<
TableOptions<TData>,
'getCoreRowModel' | 'getSortedRowModel' | 'getPaginationRowModel' | 'initialState'
> {
return {
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
...overrides,
}
}
@@ -0,0 +1,32 @@
import type { SpeakerRow } from '@/types/api'
import type { DeploymentProgress } from './types'
export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
if (speakers.length === 0) {
return { percent: 0, synced: 0, total: 0, mode: 'online' }
}
const withRevision = speakers.filter(
(s) => s.published_revision_id && s.last_applied_revision_id,
)
if (withRevision.length > 0) {
const synced = withRevision.filter(
(s) => s.published_revision_id === s.last_applied_revision_id,
).length
return {
percent: Math.round((synced / withRevision.length) * 100),
synced,
total: withRevision.length,
mode: 'revision',
}
}
const online = speakers.filter((s) => s.live?.agent_ok).length
return {
percent: Math.round((online / speakers.length) * 100),
synced: online,
total: speakers.length,
mode: 'online',
}
}
+8
View File
@@ -0,0 +1,8 @@
export * from './types'
export * from './job-status-breakdown'
export * from './module-type-breakdown'
export * from './peer-capacity-bars'
export * from './deployment-progress'
export * from './readiness-breakdown'
export * from './peer-session-breakdown'
export * from './recent-platform-activity'
@@ -0,0 +1,52 @@
import type { JobRow } from '@/types/api'
import type { BreakdownSlice } from './types'
const STATUS_BUCKETS: { keys: string[]; label: string; color: string }[] = [
{ keys: ['succeeded', 'success'], label: 'Успешно', color: 'var(--color-chart-2)' },
{ keys: ['running', 'queued'], label: 'Активные', color: 'var(--color-chart-1)' },
{ keys: ['failed', 'error', 'cancelled'], label: 'Ошибки', color: 'var(--color-destructive)' },
]
function bucketForStatus(status: string): string {
const s = status.toLowerCase()
for (const bucket of STATUS_BUCKETS) {
if (bucket.keys.includes(s)) return bucket.label
}
return 'Прочее'
}
export function jobStatusBreakdown(jobs: JobRow[]): BreakdownSlice[] {
const counts = new Map<string, number>()
for (const job of jobs) {
const label = bucketForStatus(job.status)
counts.set(label, (counts.get(label) ?? 0) + 1)
}
const slices: BreakdownSlice[] = []
for (const bucket of STATUS_BUCKETS) {
const count = counts.get(bucket.label) ?? 0
if (count > 0) {
slices.push({
key: bucket.label,
label: bucket.label,
count,
color: bucket.color,
})
}
}
const other = counts.get('Прочее') ?? 0
if (other > 0) {
slices.push({
key: 'other',
label: 'Прочее',
count: other,
color: 'var(--color-chart-4)',
})
}
return slices
}
export function jobStatusTotal(jobs: JobRow[]): number {
return jobs.length
}
@@ -0,0 +1,26 @@
import type { ModuleRow, ModuleType } from '@/types/api'
import type { BreakdownSlice } from './types'
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' },
DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' },
CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' },
IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' },
}
export function moduleTypeBreakdown(modules: ModuleRow[]): BreakdownSlice[] {
const counts = new Map<ModuleType, number>()
for (const mod of modules) {
counts.set(mod.type, (counts.get(mod.type) ?? 0) + 1)
}
return (Object.keys(TYPE_META) as ModuleType[])
.map((type) => ({
key: type,
label: TYPE_META[type].label,
count: counts.get(type) ?? 0,
color: TYPE_META[type].color,
}))
.filter((slice) => slice.count > 0)
}
@@ -0,0 +1,42 @@
import type { PeerRow, SpeakerRow } from '@/types/api'
import type { CapacityBar } from './types'
function peerLabel(peer: PeerRow): string {
return peer.name?.trim() || peer.neighbor || peer.id.slice(0, 8)
}
function speakerLabel(speaker: SpeakerRow): string {
return speaker.live?.label?.trim() || speaker.agent_domain || speaker.endpoint || speaker.id.slice(0, 8)
}
export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] {
return peers
.filter((p) => p.enabled !== false)
.slice(0, max)
.map((peer) => ({
id: peer.id,
name: peerLabel(peer),
value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10,
}))
}
export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] {
return speakers.slice(0, max).map((speaker) => {
const online = speaker.live?.agent_ok === true
const established = speaker.live?.bgp_established ?? 0
const total = speaker.live?.bgp_sessions_total ?? 0
const ratio = total > 0 ? Math.round((established / total) * 100) : online ? 100 : 15
return {
id: speaker.id,
name: speakerLabel(speaker),
value: online ? ratio : 10,
}
})
}
export function capacityUtilization(bars: CapacityBar[]): number {
if (bars.length === 0) return 0
const sum = bars.reduce((acc, bar) => acc + bar.value, 0)
return Math.round(sum / bars.length)
}
@@ -0,0 +1,41 @@
import type { PeerRow } from '@/types/api'
import type { BreakdownSlice } from './types'
export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
const enabled = peers.filter((p) => p.enabled !== false)
const established = enabled.filter((p) => p.session_state === 'Established').length
const pending = enabled.filter(
(p) => p.session_state && p.session_state !== 'Established',
).length
const disabled = peers.length - enabled.length
const slices: BreakdownSlice[] = []
if (established > 0) {
slices.push({
key: 'established',
label: 'Established',
count: established,
color: 'var(--color-chart-2)',
})
}
if (pending > 0) {
slices.push({
key: 'pending',
label: 'Не Established',
count: pending,
color: 'var(--color-warning)',
})
}
if (disabled > 0) {
slices.push({
key: 'disabled',
label: 'Выключены',
count: disabled,
color: 'var(--color-chart-4)',
})
}
return slices.length > 0
? slices
: [{ key: 'empty', label: 'Нет пиров', count: 1, color: 'var(--color-muted-foreground)' }]
}
@@ -0,0 +1,71 @@
import type { ReadyStatus } from '@/queries/monitoring'
import type { BreakdownSlice } from './types'
function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean {
if (typeof value === 'boolean') return value
if (value && typeof value === 'object') return value.ok === true
return false
}
export function readinessBreakdown(
ready: ReadyStatus | null | undefined,
healthOk: boolean,
): BreakdownSlice[] {
if (!healthOk) {
return [
{
key: 'health-fail',
label: 'API недоступен',
count: 1,
color: 'var(--color-destructive)',
},
]
}
const checks = ready?.checks ?? {}
let okCount = 0
let failCount = 0
for (const value of Object.values(checks)) {
if (checkOk(value)) okCount += 1
else failCount += 1
}
const slices: BreakdownSlice[] = [
{
key: 'health',
label: 'Health OK',
count: 1,
color: 'var(--color-chart-2)',
},
]
if (okCount > 0) {
slices.push({
key: 'checks-ok',
label: 'Checks OK',
count: okCount,
color: 'var(--color-chart-1)',
})
}
if (failCount > 0) {
slices.push({
key: 'checks-fail',
label: 'Checks fail',
count: failCount,
color: 'var(--color-warning)',
})
}
if (slices.length === 1 && okCount === 0 && failCount === 0) {
slices.push({
key: 'ready',
label: ready?.status === 'ok' ? 'Ready' : 'Ready pending',
count: 1,
color: 'var(--color-chart-4)',
})
}
return slices
}
@@ -0,0 +1,64 @@
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
import type { PlatformActivityItem } from './types'
const JOB_KIND_RU: Record<string, string> = {
module_refresh: 'Обновление модуля',
apply: 'Применение конфигурации',
rollback: 'Откат ревизии',
bird_reload: 'Перезагрузка BIRD',
}
function jobMessage(job: JobRow): string {
const kind = JOB_KIND_RU[job.kind] ?? job.kind
return `${kind} · ${job.status}`
}
export function recentPlatformActivity(
jobs: JobRow[],
revisions: RevisionRow[],
peers: PeerRow[],
speakers: SpeakerRow[],
limit = 5,
): PlatformActivityItem[] {
const items: PlatformActivityItem[] = []
for (const job of jobs.slice(0, 3)) {
items.push({
id: `job-${job.job_id}`,
message: jobMessage(job),
status: job.status,
kind: 'job',
})
}
for (const rev of revisions.slice(0, 2)) {
items.push({
id: `rev-${rev.id}`,
message: `Ревизия ${rev.id.slice(0, 8)}… · ${rev.materialized_prefix_count} префиксов`,
status: 'ok',
statusLabel: 'Создана',
kind: 'revision',
})
}
for (const peer of peers.filter((p) => p.session_mismatch).slice(0, 2)) {
items.push({
id: `peer-${peer.id}`,
message: `Mismatch сессии: ${peer.name ?? peer.neighbor}`,
status: 'mismatch',
kind: 'network',
})
}
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) {
items.push({
id: `speaker-${speaker.id}`,
message: `Нода недоступна: ${speaker.live?.label ?? speaker.endpoint}`,
status: speaker.live?.agent_ok === false ? 'error' : 'warning',
kind: 'network',
})
}
return items.slice(0, limit)
}
+28
View File
@@ -0,0 +1,28 @@
export type BreakdownSlice = {
key: string
label: string
count: number
color: string
}
export type CapacityBar = {
id: string
name: string
/** 0100 utilization */
value: number
}
export type PlatformActivityItem = {
id: string
message: string
status: string
statusLabel?: string
kind: 'job' | 'revision' | 'network'
}
export type DeploymentProgress = {
percent: number
synced: number
total: number
mode: 'revision' | 'online'
}
+17
View File
@@ -1,4 +1,6 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON } from '@/lib/api-client'
import type {
FirewallClient,
@@ -51,8 +53,23 @@ export function useApproveFirewallClient() {
mutationFn: (id: string) =>
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Клиент одобрен')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить'),
})
}
export function useDeleteFirewallClient() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<void>(`/v1/firewall/clients/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Клиент удалён')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить'),
})
}
+1 -1
View File
@@ -58,7 +58,7 @@ export function overviewRevisionsQueryOptions() {
export function overviewJobsQueryOptions() {
return queryOptions<JobsResponse>({
queryKey: overviewKeys.jobs(),
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'),
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=100'),
staleTime: 15_000,
})
}
+63 -248
View File
@@ -1,36 +1,31 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import {
Boxes,
CheckCircle,
Clock,
GitBranch,
Info,
Network,
Play,
Plus,
Radio,
RefreshCw,
Activity,
Share2,
Tags,
Gauge,
XCircle,
} from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { CheckCircle, Info, RefreshCw, XCircle } from 'lucide-react'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import {
DashboardNetworkCapacityCard,
DashboardOperationsFlowCard,
DashboardPlatformCard,
} from '@/components/analytics'
import { DataGridCard } from '@/components/data-grid-shell'
import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions'
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
import { PageHeader } from '@/components/page-header'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
import {
aggregateNetworkMetrics,
moduleNameById,
overviewHealthQueryOptions,
overviewJobsQueryOptions,
@@ -38,7 +33,6 @@ import {
overviewPeersQueryOptions,
overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions,
runningJobCount,
} from '@/queries/overview'
export const Route = createFileRoute('/_auth/dashboard')({
@@ -78,60 +72,13 @@ function DashboardComponent() {
const speakers = speakersQ.data?.items ?? []
const revisions = revisionsQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const modulesHasMore = modulesQ.data?.has_more ?? false
const peersHasMore = peersQ.data?.has_more ?? false
const speakersHasMore = speakersQ.data?.has_more ?? false
const revisionsHasMore = revisionsQ.data?.has_more ?? false
const net = aggregateNetworkMetrics(peers, speakers)
const running = runningJobCount(jobs)
const nameById = moduleNameById(modules)
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix)
const items: SectionCardItem[] = [
{
label: 'Модули',
value: initialLoading ? '—' : String(modules.length),
icon: <Boxes className="size-4" />,
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
onClick: () => window.location.assign('/modules'),
},
{
label: 'Пиры',
value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
icon: <GitBranch className="size-4" />,
hint: countBadge(peers.length, peersHasMore, 'Established / включённых'),
badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined,
variant: net.peersMismatch > 0 ? 'warning' : 'default',
onClick: () => window.location.assign('/network?tab=peers'),
},
{
label: 'Спикеры',
value: initialLoading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
icon: <Radio className="size-4" />,
hint: countBadge(speakers.length, speakersHasMore, 'online / всего'),
variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default',
onClick: () => window.location.assign('/network?tab=overview'),
},
{
label: 'Ревизии',
value: initialLoading ? '—' : String(revisions.length),
icon: <Activity className="size-4" />,
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
onClick: () => window.location.assign('/operations'),
},
{
label: 'Активных задач',
value: initialLoading ? '—' : String(running),
icon: <Clock className="size-4" />,
hint: 'queued и running',
onClick: () => window.location.assign('/operations?tab=jobs'),
},
]
const activityLoading =
refreshing && jobs.length === 0 && revisions.length === 0
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-5">
<PageHeader
title="Обзор"
description={
@@ -162,45 +109,56 @@ function DashboardComponent() {
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
/>
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} />}
{initialLoading ? (
<AnalyticsDashboardSkeleton />
) : (
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
<div className="lg:row-span-2">
<DashboardPlatformCard
modules={modules}
peers={peers}
speakers={speakers}
jobs={jobs}
revisions={revisions}
/>
</div>
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsFlowCard jobs={jobs} modules={modules} />
</div>
)}
<div className="grid gap-4 lg:grid-cols-3">
<RecentJobsCard jobs={jobs} nameById={nameById} loading={refreshing} />
<RecentRevisionsCard revisions={revisions} loading={refreshing} />
<NetworkStatusCard peers={peers} speakers={speakers} loading={refreshing} />
<div className="grid gap-4 lg:grid-cols-2">
<DataGridCard
title="Недавние задачи"
description="Последние фоновые операции"
className="h-full"
>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
)}
</DataGridCard>
<DataGridCard
title="Последние ревизии"
description="История конфигураций"
className="h-full"
>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
)}
</DataGridCard>
</div>
<Card>
<Card className="gap-0">
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Быстрые действия</CardTitle>
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 p-4">
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
<Plus className="size-4" />
Создать модуль
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/directories')}>
<Tags className="size-4" />
Добавить community
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=overview')}>
<Network className="size-4" />
Сеть
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=peers')}>
<Share2 className="size-4" />
Добавить пира
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/operations')}>
<Play className="size-4" />
Деплой (Apply)
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/monitoring')}>
<Gauge className="size-4" />
Мониторинг
</Button>
</CardContent>
<DashboardQuickActions />
</Card>
</div>
)
@@ -255,146 +213,3 @@ function HealthAlert({
</Alert>
)
}
function RecentJobsCard({
jobs,
nameById,
loading,
}: {
jobs: import('@/types/api').JobRow[]
nameById: Map<string, string>
loading: boolean
}) {
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Недавние задачи</CardTitle>
<CardDescription>Последние фоновые операции</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && jobs.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : jobs.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет задач</p>
) : (
<ul className="flex flex-col gap-1">
{jobs.slice(0, 8).map((j) => (
<li
key={j.job_id}
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-mono text-xs text-muted-foreground">{j.kind}</span>
<span className="truncate text-xs">
{j.meta?.module_id ? nameById.get(String(j.meta.module_id)) ?? '' : ''}
</span>
</span>
<span
className={
j.status === 'succeeded'
? 'text-xs text-success'
: j.status === 'failed'
? 'text-xs text-destructive'
: 'text-xs text-muted-foreground'
}
>
{j.status}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}
function RecentRevisionsCard({
revisions,
loading,
}: {
revisions: import('@/types/api').RevisionRow[]
loading: boolean
}) {
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Последние ревизии</CardTitle>
<CardDescription>История конфигураций</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && revisions.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : revisions.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет ревизий</p>
) : (
<ul className="flex flex-col gap-1">
{revisions.slice(0, 8).map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
>
<span className="truncate font-mono text-xs text-muted-foreground">{r.id.slice(0, 10)}</span>
<span className="text-xs text-muted-foreground">
{new Date(r.created_at).toLocaleString('ru-RU')}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}
function NetworkStatusCard({
peers,
speakers,
loading,
}: {
peers: import('@/types/api').PeerRow[]
speakers: import('@/types/api').SpeakerRow[]
loading: boolean
}) {
const m = aggregateNetworkMetrics(peers, speakers)
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Состояние сети</CardTitle>
<CardDescription>BGP-сессии и спикеры</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && peers.length === 0 && speakers.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : (
<div className="flex flex-col gap-2 px-1 py-1 text-sm">
<Row label="Пиры Established" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
<Row label="Спикеры online" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
{m.peersMismatch > 0 ? (
<Row label="Mismatches" value={String(m.peersMismatch)} variant="warning" />
) : null}
</div>
)}
</CardContent>
</Card>
)
}
function Row({
label,
value,
variant = 'default',
}: {
label: string
value: string
variant?: 'default' | 'warning'
}) {
return (
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">{label}</span>
<span className={variant === 'warning' ? 'font-medium text-warning-foreground' : 'font-medium tabular-nums'}>
{value}
</span>
</div>
)
}
+44 -92
View File
@@ -3,19 +3,12 @@ import { useQuery } from '@tanstack/react-query'
import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { DataGridCard } from '@/components/data-grid-shell'
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
@@ -94,91 +87,50 @@ function DirectoriesComponent() {
</TabsList>
<TabsContent value="communities" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Сообщества BGP</CardTitle>
<CardDescription>Теги для префиксов в фильтрах BIRD</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={communities}
isLoading={communitiesQ.isLoading}
isError={communitiesQ.isError}
error={communitiesQ.error}
empty={communities.length === 0}
emptyTitle="Нет сообществ"
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => communitiesQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Значение</TableHead>
<TableHead>Тип</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.title}</TableCell>
<TableCell className="font-mono text-xs">{c.community}</TableCell>
<TableCell>
<Badge variant="outline">community</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
<DataGridCard
title="Сообщества BGP"
description="Теги для префиксов в фильтрах BIRD"
>
<QueryState
data={communities}
isLoading={communitiesQ.isLoading}
isError={communitiesQ.isError}
error={communitiesQ.error}
empty={communities.length === 0}
emptyTitle="Нет сообществ"
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => communitiesQ.refetch()}
>
{(items) => (
<DirectoriesCommunitiesGrid
items={items}
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
<TabsContent value="doh" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">DoH профили</CardTitle>
<CardDescription>Резолверы DNS-over-HTTPS для доменных модулей</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={dohProfiles}
isLoading={dohQ.isLoading}
isError={dohQ.isError}
error={dohQ.error}
empty={dohProfiles.length === 0}
emptyTitle="Нет DoH профилей"
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => dohQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>URL</TableHead>
<TableHead>По умолчанию</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name ?? p.url}</TableCell>
<TableCell className="font-mono text-xs">{p.url}</TableCell>
<TableCell>
<Badge variant="outline"></Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
<DataGridCard title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
<QueryState
data={dohProfiles}
isLoading={dohQ.isLoading}
isError={dohQ.isError}
error={dohQ.error}
empty={dohProfiles.length === 0}
emptyTitle="Нет DoH профилей"
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => dohQ.refetch()}
>
{(items) => (
<DirectoriesDohGrid
items={items}
isLoading={dohQ.isFetching && !dohQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
</Tabs>
</div>
+71 -123
View File
@@ -10,19 +10,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid'
import { PageHeader } from '@/components/page-header'
import { CommunitySelect } from '@/components/modules/community-select'
import { StatusBadge } from '@/components/status-badge'
import { communityLabel } from '@/lib/modules/helpers'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
import {
firewallClientsQueryOptions,
@@ -30,9 +24,9 @@ import {
firewallRulesQueryOptions,
useApproveFirewallClient,
useCreateFirewallRule,
useDeleteFirewallClient,
useDeleteFirewallRule,
} from '@/queries/firewall'
import type { BgpCommunity, FirewallClient } from '@/types/api'
function httpsOrigin(origin: string): string {
try {
@@ -54,6 +48,7 @@ function FirewallPage() {
const clientsQ = useQuery(firewallClientsQueryOptions())
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient()
const deleteClient = useDeleteFirewallClient()
const createRule = useCreateFirewallRule()
const deleteRule = useDeleteFirewallRule()
@@ -79,8 +74,13 @@ function FirewallPage() {
const communities = communitiesQ.data?.items ?? []
const clients = clientsQ.data?.items ?? []
const pending = clients.filter((c) => c.status === 'pending')
const { activeClients, pending } = useMemo(() => {
const all = clientsQ.data?.items ?? []
return {
activeClients: all.filter((c) => c.status !== 'revoked'),
pending: all.filter((c) => c.status === 'pending'),
}
}, [clientsQ.data?.items])
const rules = rulesQ.data?.items ?? []
const installCmd = useMemo(() => {
@@ -187,13 +187,31 @@ function FirewallPage() {
<Tabs defaultValue="clients">
<TabsList>
<TabsTrigger value="clients">Клиенты ({clients.length})</TabsTrigger>
<TabsTrigger value="clients">Клиенты ({activeClients.length})</TabsTrigger>
<TabsTrigger value="rules">Правила ({rules.length})</TabsTrigger>
<TabsTrigger value="requests">Запросы ({pending.length})</TabsTrigger>
</TabsList>
<TabsContent value="clients" className="mt-4">
<ClientsTable clients={clients} onApprove={(id) => approve.mutate(id)} />
<QueryState
data={clientsQ.data}
isLoading={clientsQ.isLoading}
isError={clientsQ.isError}
error={clientsQ.error}
onRetry={() => void clientsQ.refetch()}
skeleton={<TableSkeleton rows={5} cols={6} />}
>
{() => (
<FirewallClientsGrid
clients={activeClients}
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
/>
)}
</QueryState>
</TabsContent>
<TabsContent value="rules" className="mt-4 space-y-4">
@@ -243,119 +261,49 @@ function FirewallPage() {
Добавить правило
</Button>
</div>
<RulesTable
rules={rules}
communities={communities}
onDelete={(id) => deleteRule.mutate(id)}
/>
<QueryState
data={rulesQ.data}
isLoading={rulesQ.isLoading}
isError={rulesQ.isError}
error={rulesQ.error}
onRetry={() => void rulesQ.refetch()}
skeleton={<TableSkeleton rows={5} cols={5} />}
>
{() => (
<FirewallRulesGrid
rules={rules}
communities={communities}
isLoading={rulesQ.isFetching && !rulesQ.isLoading}
onDelete={(id) => deleteRule.mutate(id)}
deletePending={deleteRule.isPending}
/>
)}
</QueryState>
</TabsContent>
<TabsContent value="requests" className="mt-4">
<ClientsTable
clients={pending}
onApprove={(id) => approve.mutate(id)}
emptyTitle="Нет pending-запросов"
/>
<QueryState
data={clientsQ.data}
isLoading={clientsQ.isLoading}
isError={clientsQ.isError}
error={clientsQ.error}
onRetry={() => void clientsQ.refetch()}
skeleton={<TableSkeleton rows={3} cols={6} />}
>
{() => (
<FirewallClientsGrid
clients={pending}
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
emptyTitle="Нет pending-запросов"
/>
)}
</QueryState>
</TabsContent>
</Tabs>
</div>
)
}
function ClientsTable({
clients,
onApprove,
emptyTitle = 'Нет клиентов',
}: {
clients: FirewallClient[]
onApprove: (id: string) => void
emptyTitle?: string
}) {
if (clients.length === 0) {
return <p className="text-muted-foreground text-sm">{emptyTitle}</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Last seen</TableHead>
<TableHead>Apply</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{clients.map((c) => (
<TableRow key={c.id}>
<TableCell>
<div className="font-medium">{c.name}</div>
<div className="text-muted-foreground text-xs">{c.hostname || c.token_prefix}</div>
</TableCell>
<TableCell>
<StatusBadge status={c.status} />
</TableCell>
<TableCell className="text-xs">{c.last_seen_at?.slice(0, 19) ?? '—'}</TableCell>
<TableCell className="text-xs">
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</TableCell>
<TableCell>
{c.status === 'pending' ? (
<Button size="sm" variant="outline" onClick={() => onApprove(c.id)}>
Approve
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function RulesTable({
rules,
communities,
onDelete,
}: {
rules: { id: string; priority: number; action: string; community_id?: string | null; comment?: string }[]
communities: BgpCommunity[]
onDelete: (id: string) => void
}) {
if (rules.length === 0) {
return <p className="text-muted-foreground text-sm">Нет правил blocklist пуст (default accept).</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Действие</TableHead>
<TableHead>Community</TableHead>
<TableHead>Комментарий</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>
<StatusBadge status={r.action} label={r.action} />
</TableCell>
<TableCell className="text-sm">
{r.community_id ? communityLabel(r.community_id, communities) : 'Все'}
</TableCell>
<TableCell>{r.comment || '—'}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" onClick={() => onDelete(r.id)}>
Удалить
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+27 -79
View File
@@ -1,25 +1,15 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Boxes, Plus, RefreshCw } from 'lucide-react'
import { Plus, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { Badge } from '@/components/reui/badge'
import { DataGridCard } from '@/components/data-grid-shell'
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { TruncatedText } from '@/components/truncated-text'
import { modulesListQueryOptions } from '@/queries/modules'
import type { ModuleRow } from '@/types/api'
export const Route = createFileRoute('/_auth/modules/')({
component: ModulesListComponent,
@@ -48,76 +38,34 @@ function ModulesListComponent() {
}
/>
<Card>
<CardHeader className="flex flex-row items-center justify-between border-b py-3">
<CardTitle className="text-base">Все модули</CardTitle>
<DataGridCard
title="Все модули"
actions={
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Создать
</Button>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={query.data?.items}
isLoading={query.isLoading}
isError={query.isError}
error={query.error}
empty={query.data?.items?.length === 0}
emptyTitle="Нет модулей"
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
{(items) => <ModulesTable items={items} />}
</QueryState>
</CardContent>
</Card>
}
>
<QueryState
data={query.data?.items}
isLoading={query.isLoading}
isError={query.isError}
error={query.error}
empty={query.data?.items?.length === 0}
emptyTitle="Нет модулей"
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
{(items) => (
<ModulesListGrid
items={items}
isLoading={query.isFetching && !query.isLoading}
/>
)}
</QueryState>
</DataGridCard>
</div>
)
}
function ModulesTable({ items }: { items: ModuleRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Приоритет</TableHead>
<TableHead>Состояние</TableHead>
<TableHead>Обновлено</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((m) => (
<TableRow
key={m.id}
className="cursor-pointer hover:bg-muted/40"
onClick={() => (window.location.href = `/modules/${m.id}`)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Boxes className="size-4 text-muted-foreground" />
<TruncatedText className="max-w-[280px]">{m.name}</TruncatedText>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell className="font-mono text-sm tabular-nums">{m.priority}</TableCell>
<TableCell>
{m.enabled ? (
<Badge variant="success">включён</Badge>
) : (
<Badge variant="secondary">выключен</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+40 -133
View File
@@ -1,6 +1,6 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Activity, AlertTriangle, Bird, Database, Gauge, HardDrive, HeartPulse, Info, ListTodo, RefreshCw, ShieldCheck } from 'lucide-react'
import { Activity, AlertTriangle, Bird, Database, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
@@ -8,19 +8,15 @@ import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Separator } from '@evobgp/ui/components/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import {
DashboardOperationsFlowCard,
MonitoringHealthCard,
} from '@/components/analytics'
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
import {
monitoringHealthQueryOptions,
@@ -31,6 +27,7 @@ import {
} from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network'
import { operationsJobsQueryOptions } from '@/queries/operations'
import { overviewModulesQueryOptions } from '@/queries/overview'
export const Route = createFileRoute('/_auth/monitoring')({
component: MonitoringComponent,
@@ -49,53 +46,25 @@ function MonitoringComponent() {
const versionQ = useQuery(monitoringVersionQueryOptions())
const birdQ = useQuery(networkBirdQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const modulesQ = useQuery(overviewModulesQueryOptions())
const refreshing =
healthQ.isFetching ||
readyQ.isFetching ||
versionQ.isFetching ||
birdQ.isFetching ||
jobsQ.isFetching
jobsQ.isFetching ||
modulesQ.isFetching
const jobs = jobsQ.data?.items ?? []
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const modules = modulesQ.data?.items ?? []
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const versionText = formatVersion(versionQ.data)
const items: SectionCardItem[] = [
{
label: 'Общий статус',
value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }),
icon: <Gauge className="size-4" />,
hint: overallHint({ health: healthQ.data, jobsFailed: failed }),
},
{
label: 'BGP сессии',
value: birdQ.data
? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}`
: '—',
icon: <Bird className="size-4" />,
hint: birdQ.data?.birdc_configured
? 'Established / total на API-хосте'
: 'birdc не настроен',
},
{
label: 'Задачи',
value: running,
icon: <Activity className="size-4" />,
hint: `активных из ${jobs.length}`,
variant: failed > 0 ? 'warning' : 'default',
},
{
label: 'Версия',
value: versionText,
icon: <Gauge className="size-4" />,
hint: versionQ.data?.git_sha ?? versionQ.data?.build_time ?? 'GET /v1/version',
},
]
const analyticsLoading =
(healthQ.isLoading || readyQ.isLoading || jobsQ.isLoading) && jobs.length === 0
function refetchAll() {
void healthQ.refetch()
@@ -103,6 +72,7 @@ function MonitoringComponent() {
void versionQ.refetch()
void birdQ.refetch()
void jobsQ.refetch()
void modulesQ.refetch()
}
const failedJobs = jobs
@@ -130,7 +100,29 @@ function MonitoringComponent() {
</TabsList>
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />}
{analyticsLoading ? (
<AnalyticsDashboardSkeleton />
) : (
<div className="grid gap-4 lg:grid-cols-2">
<MonitoringHealthCard
healthOk={healthQ.data?.ok ?? false}
ready={readyQ.data}
loading={healthQ.isLoading || readyQ.isLoading}
/>
<DashboardOperationsFlowCard jobs={jobs} modules={modules} loading={jobsQ.isLoading} />
</div>
)}
<Alert className="border-muted bg-muted/30">
<Info className="size-4" />
<AlertTitle className="text-sm">
Версия API: {versionText}
{versionQ.data?.git_sha ? ` · ${versionQ.data.git_sha.slice(0, 8)}` : ''}
</AlertTitle>
<AlertDescription className="text-xs">
{overallHint({ health: healthQ.data, jobsFailed: failed })}
</AlertDescription>
</Alert>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
@@ -147,7 +139,7 @@ function MonitoringComponent() {
skeleton={<div className="h-40" />}
onRetry={() => readyQ.refetch()}
>
{(ready) => <ReadyTable health={healthQ.data} ready={ready} />}
{(ready) => <MonitoringReadyGrid health={healthQ.data} ready={ready} />}
</QueryState>
</CardContent>
</Card>
@@ -186,7 +178,7 @@ function MonitoringComponent() {
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-4 text-sm">
<Metric label="Активных" value={running} />
<Metric label="Активных" value={jobs.filter((j) => j.status === 'running' || j.status === 'queued').length} />
<Metric
label="С ошибками"
value={failed}
@@ -329,97 +321,12 @@ interface OverallInput {
jobsFailed: number
}
function overallStatusLabel(input: OverallInput): string {
if (!input.health?.ok) return 'Ошибка'
if (input.jobsFailed > 0) return 'Внимание'
if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание'
return 'В норме'
}
function overallHint(input: OverallInput): string {
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
return 'Все системы работают в штатном режиме'
}
function ReadyTable({
health,
ready,
}: {
health?: { ok?: boolean; status?: string; error?: string } | null
ready: ReadyStatus
}) {
const checks = ready.checks ?? {}
const iconByKey: Record<string, typeof Database> = {
postgres: Database,
store: HardDrive,
jobs: ListTodo,
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[55%]">Проверка</TableHead>
<TableHead>Статус</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<HeartPulse className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Liveness</p>
<p className="text-xs text-muted-foreground">/v1/health</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={health?.ok ? 'default' : 'destructive'}>
{health?.ok ? 'OK' : 'Ошибка'}
</Badge>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Readiness</p>
<p className="text-xs text-muted-foreground">/v1/ready</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={ready.status === 'ok' ? 'default' : 'secondary'}>
{ready.status ?? '—'}
</Badge>
</TableCell>
</TableRow>
{Object.entries(checks).map(([key, value]) => {
const ok = typeof value === 'boolean' ? value : value?.ok !== false
const Icon = iconByKey[key] ?? ListTodo
return (
<TableRow key={key}>
<TableCell>
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{key}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={ok ? 'default' : 'destructive'}>{ok ? 'OK' : 'Ошибка'}</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
if (!bird.birdc_configured) {
return (
+79 -158
View File
@@ -5,22 +5,19 @@ import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Info, RefreshCw } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { Badge } from '@/components/reui/badge'
import { StatusBadge } from '@/components/status-badge'
import {
DashboardNetworkCapacityCard,
NetworkOverviewAnalyticsCard,
} from '@/components/analytics'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
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 { aggregateNetworkMetrics } from '@/queries/overview'
import { overviewJobsQueryOptions } from '@/queries/overview'
export const Route = createFileRoute('/_auth/network')({
component: NetworkComponent,
@@ -36,11 +33,13 @@ 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 peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const net = aggregateNetworkMetrics(peers, speakers)
const jobs = jobsQ.data?.items ?? []
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
function refetchAll() {
void peersQ.refetch()
@@ -78,84 +77,81 @@ function NetworkComponent() {
</TabsList>
<TabsContent value="overview" className="mt-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Сводка сети</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
<Field label="Пиры всего" value={String(net.peersTotal)} />
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} />
<Field label="Спикеры всего" value={String(net.speakersTotal)} />
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} />
{net.peersMismatch > 0 ? (
<Field label="Mismatches" value={String(net.peersMismatch)} />
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
<CardDescription>Статус birdc на хосте API</CardDescription>
</CardHeader>
<CardContent className="p-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>
</CardContent>
</Card>
<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>
<Card className="mt-4">
<CardHeader className="border-b py-3">
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
<CardDescription>Статус birdc на хосте API</CardDescription>
</CardHeader>
<CardContent className="p-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>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="peers" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Пиры</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={peers}
isLoading={peersQ.isLoading}
isError={peersQ.isError}
error={peersQ.error}
empty={peers.length === 0}
emptyTitle="Нет пиров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => peersQ.refetch()}
>
{(items) => <PeersTable items={items} />}
</QueryState>
</CardContent>
</Card>
<DataGridCard title="Пиры">
<QueryState
data={peers}
isLoading={peersQ.isLoading}
isError={peersQ.isError}
error={peersQ.error}
empty={peers.length === 0}
emptyTitle="Нет пиров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => peersQ.refetch()}
>
{(items) => (
<NetworkPeersGrid
items={items}
isLoading={peersQ.isFetching && !peersQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
<TabsContent value="speakers" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Спикеры</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={speakers}
isLoading={speakersQ.isLoading}
isError={speakersQ.isError}
error={speakersQ.error}
empty={speakers.length === 0}
emptyTitle="Нет спикеров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => speakersQ.refetch()}
>
{(items) => <SpeakersTable items={items} />}
</QueryState>
</CardContent>
</Card>
<DataGridCard title="Спикеры">
<QueryState
data={speakers}
isLoading={speakersQ.isLoading}
isError={speakersQ.isError}
error={speakersQ.error}
empty={speakers.length === 0}
emptyTitle="Нет спикеров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => speakersQ.refetch()}
>
{(items) => (
<NetworkSpeakersGrid
items={items}
isLoading={speakersQ.isFetching && !speakersQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
<TabsContent value="control-plane" className="mt-4">
@@ -196,78 +192,3 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
</div>
)
}
function PeersTable({ items }: { items: import('@/types/api').PeerRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Neighbor</TableHead>
<TableHead>ASN</TableHead>
<TableHead>Состояние</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name ?? p.neighbor}</TableCell>
<TableCell className="font-mono text-xs">{p.neighbor}</TableCell>
<TableCell className="font-mono text-xs">{p.remote_asn ?? '—'}</TableCell>
<TableCell>
<StatusBadge status={p.session_state} />
{p.session_mismatch ? (
<Badge variant="warning" className="ml-1">
mismatch
</Badge>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function SpeakersTable({ items }: { items: import('@/types/api').SpeakerRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Endpoint</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Agent</TableHead>
<TableHead>BGP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-mono text-xs">{s.endpoint}</TableCell>
<TableCell>
<Badge variant="outline">{s.role}</Badge>
</TableCell>
<TableCell>
{s.live?.agent_ok === true ? (
<StatusBadge status="ok" label="online" />
) : s.live?.agent_ok === false ? (
<StatusBadge status="error" label="offline" />
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell>
{s.live ? (
<span className="text-xs">
{s.live.bgp_established ?? 0} / {s.live.bgp_sessions_total ?? 0}
</span>
) : (
'—'
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+62 -244
View File
@@ -1,39 +1,26 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
import { Info, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState, useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { OperationsAnalyticsCard } from '@/components/analytics'
import { DataGridCard } from '@/components/data-grid-shell'
import { SelectMenu } from '@/components/select-field'
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
import { apiMutate, waitForJob } from '@/lib/api-client'
import type { JobRow } from '@/types/api'
export const Route = createFileRoute('/_auth/operations')({
component: OperationsComponent,
@@ -58,32 +45,6 @@ function OperationsComponent() {
const nameById = moduleNameById(modulesQ.data?.items ?? [])
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const failed = jobs.filter(
(j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const items: SectionCardItem[] = [
{
label: 'Ревизий',
value: revisions.length,
icon: <Activity className="size-4" />,
hint: 'история конфигов',
},
{
label: 'Активных задач',
value: running,
icon: <Clock className="size-4" />,
hint: 'queued и running',
},
{
label: 'Задач с ошибкой',
value: failed,
icon: <AlertTriangle className="size-4" />,
hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет',
variant: failed > 0 ? 'warning' : 'default',
},
]
function refetchAll() {
void revisionsQ.refetch()
@@ -167,7 +128,11 @@ function OperationsComponent() {
/>
</div>
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
{revisionsQ.isLoading ? (
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
) : (
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
)}
<Tabs defaultValue={search.tab}>
<TabsList>
@@ -177,24 +142,25 @@ function OperationsComponent() {
</TabsList>
<TabsContent value="revisions" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">История ревизий</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={revisions}
isLoading={revisionsQ.isLoading}
isError={revisionsQ.isError}
error={revisionsQ.error}
empty={revisions.length === 0}
emptyTitle="Нет ревизий"
onRetry={() => revisionsQ.refetch()}
>
{(items) => <RevisionsTable items={items} qc={qc} />}
</QueryState>
</CardContent>
</Card>
<DataGridCard title="История ревизий">
<QueryState
data={revisions}
isLoading={revisionsQ.isLoading}
isError={revisionsQ.isError}
error={revisionsQ.error}
empty={revisions.length === 0}
emptyTitle="Нет ревизий"
onRetry={() => revisionsQ.refetch()}
>
{(items) => (
<OperationsRevisionsGrid
items={items}
qc={qc}
isLoading={revisionsQ.isFetching && !revisionsQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
<TabsContent value="diff" className="mt-4">
@@ -202,168 +168,32 @@ function OperationsComponent() {
</TabsContent>
<TabsContent value="jobs" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Задачи</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={jobs}
isLoading={jobsQ.isLoading}
isError={jobsQ.isError}
error={jobsQ.error}
empty={jobs.length === 0}
emptyTitle="Нет задач"
onRetry={() => jobsQ.refetch()}
>
{(items) => <JobsTable items={items} nameById={nameById} qc={qc} />}
</QueryState>
</CardContent>
</Card>
<DataGridCard title="Задачи">
<QueryState
data={jobs}
isLoading={jobsQ.isLoading}
isError={jobsQ.isError}
error={jobsQ.error}
empty={jobs.length === 0}
emptyTitle="Нет задач"
onRetry={() => jobsQ.refetch()}
>
{(items) => (
<OperationsJobsGrid
items={items}
nameById={nameById}
qc={qc}
isLoading={jobsQ.isFetching && !jobsQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
</Tabs>
</div>
)
}
function RevisionsTable({
items,
qc,
}: {
items: import('@/types/api').RevisionRow[]
qc: import('@tanstack/react-query').QueryClient
}) {
const rollbackMutation = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
onSuccess: () => {
toast.success('Откат выполнен')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
})
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.id.slice(0, 12)}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(r.created_at).toLocaleString('ru-RU')}
</TableCell>
<TableCell className="font-mono text-sm tabular-nums">
{r.materialized_prefix_count}
</TableCell>
<TableCell>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" className="text-destructive">
<RefreshCw className="size-3.5" />
</Button>
}
title={`Откатиться к ревизии ${r.id.slice(0, 8)}…?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
confirmLabel="Откатить"
destructive
onConfirm={() => rollbackMutation.mutate(r.id)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function JobsTable({
items,
nameById,
qc,
}: {
items: JobRow[]
nameById: Map<string, string>
qc: import('@tanstack/react-query').QueryClient
}) {
const cancelMutation = useMutation({
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
onSuccess: () => {
toast.success('Задача отменена')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
})
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Вид</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Завершена</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((j) => (
<TableRow key={j.job_id}>
<TableCell className="font-medium">
<div className="flex flex-col gap-0.5">
<span>{j.kind}</span>
{j.meta?.module_id ? (
<span className="text-xs text-muted-foreground">
{nameById.get(String(j.meta.module_id)) ?? String(j.meta.module_id)}
</span>
) : null}
</div>
</TableCell>
<TableCell>
<StatusBadgeColored status={j.status} />
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell>
{j.status === 'running' || j.status === 'queued' ? (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
onClick={() => cancelMutation.mutate(j.job_id)}
>
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function StatusBadgeColored({ status }: { status: string }) {
const cls =
status === 'succeeded'
? 'text-success'
: status === 'failed' || status === 'cancelled'
? 'text-destructive'
: 'text-info'
return <span className={`text-sm font-medium ${cls}`}>{status}</span>
}
function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) {
const [a, setA] = useState('')
const [b, setB] = useState('')
@@ -386,33 +216,21 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
<div className="flex flex-wrap items-end gap-3">
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия A</span>
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
<SelectMenu
items={revisionItems}
value={a}
placeholder="Выберите"
onValueChange={(v) => v && setA(v)}
/>
</div>
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия B</span>
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
<SelectMenu
items={revisionItems}
value={b}
placeholder="Выберите"
onValueChange={(v) => v && setB(v)}
/>
</div>
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
Сравнить
+33 -138
View File
@@ -5,24 +5,16 @@ import { toast } from 'sonner'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { DataGridCard } from '@/components/data-grid-shell'
import { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid'
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { LoadingButton } from '@/components/loading-button'
import { operationsJobsQueryOptions } from '@/queries/operations'
import { modulesListQueryOptions } from '@/queries/modules'
@@ -108,82 +100,33 @@ function ScheduleComponent() {
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Card>
<CardHeader>
<CardTitle className="text-base">Модули</CardTitle>
<CardDescription>Расписание обновления и ручной запуск ingest</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={modules}
isLoading={modulesQ.isLoading}
isError={modulesQ.isError}
error={modulesQ.error}
empty={modules.length === 0}
emptyTitle="Нет модулей"
onRetry={() => modulesQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Модуль</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Расписание</TableHead>
<TableHead>Обновлено</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="w-32 text-right" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.name}</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell>
{m.enabled ? (
<Badge variant="default">Вкл</Badge>
) : (
<Badge variant="secondary">Выкл</Badge>
)}
</TableCell>
<TableCell className="text-right">
<LoadingButton
size="sm"
variant="secondary"
loading={!!refreshing[m.id]}
onClick={() => refreshMutation.mutate(m.id)}
>
<RefreshCw />
Обновить
</LoadingButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
<DataGridCard
title="Модули"
description="Расписание обновления и ручной запуск ingest"
>
<QueryState
data={modules}
isLoading={modulesQ.isLoading}
isError={modulesQ.isError}
error={modulesQ.error}
empty={modules.length === 0}
emptyTitle="Нет модулей"
onRetry={() => modulesQ.refetch()}
>
{(items) => (
<ScheduleModulesGrid
items={items}
refreshing={refreshing}
onRefresh={(id) => refreshMutation.mutate(id)}
isLoading={modulesQ.isFetching && !modulesQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
<Card>
<CardHeader>
<CardTitle className="text-base">Задачи</CardTitle>
<CardDescription>Последние задачи из API</CardDescription>
</CardHeader>
<CardContent className="p-0">
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
</CardContent>
</Card>
<DataGridCard title="Задачи" description="Последние задачи из API">
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
</DataGridCard>
</div>
)
}
@@ -196,68 +139,20 @@ function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
return (
<Tabs defaultValue="all">
<TabsList>
<TabsList className="m-3 mb-0">
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger>
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger>
</TabsList>
<TabsContent value="all" className="mt-0">
<JobsTable items={jobs} loading={loading} />
<ScheduleJobsGrid items={jobs} isLoading={loading} />
</TabsContent>
<TabsContent value="refresh" className="mt-0">
<JobsTable items={refresh} loading={loading} />
<ScheduleJobsGrid items={refresh} isLoading={loading} />
</TabsContent>
<TabsContent value="failed" className="mt-0">
<JobsTable items={failed} loading={loading} />
<ScheduleJobsGrid items={failed} isLoading={loading} />
</TabsContent>
</Tabs>
)
}
function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) {
if (loading) return <div className="p-6 text-center text-sm text-muted-foreground">Загрузка</div>
if (items.length === 0)
return <div className="p-6 text-center text-sm text-muted-foreground">Нет задач</div>
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Вид</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Завершена</TableHead>
<TableHead>Ошибка</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((j) => (
<TableRow key={j.job_id}>
<TableCell className="font-medium">{j.kind}</TableCell>
<TableCell>
<Badge
variant={
j.status === 'succeeded'
? 'default'
: j.status === 'failed'
? 'destructive'
: 'secondary'
}
>
{j.status}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="max-w-xs truncate text-xs text-destructive">
{j.error ?? ''}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+7 -19
View File
@@ -6,16 +6,10 @@ import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
import { toast } from 'sonner'
@@ -141,21 +135,15 @@ function SettingsComponent() {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Label htmlFor="theme-select">Тема</Label>
<Select
<SelectField
id="theme-select"
label="Тема"
items={[...THEME_SELECT_ITEMS]}
value={theme ?? 'system'}
placeholder="Выберите тему"
triggerClassName="max-w-xs"
onValueChange={(v) => v && setTheme(v)}
>
<SelectTrigger id="theme-select" className="w-full max-w-xs">
<SelectValue placeholder="Выберите тему" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Светлая</SelectItem>
<SelectItem value="dark">Тёмная</SelectItem>
<SelectItem value="system">Как в системе</SelectItem>
</SelectContent>
</Select>
/>
</CardContent>
</Card>
</div>
+36 -81
View File
@@ -8,23 +8,12 @@ import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { SelectField } from '@/components/select-field'
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { LoadingButton } from '@/components/loading-button'
@@ -250,31 +239,20 @@ function TenantSettingsComponent() {
>
{() => (
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>Авто-очистка включена</Label>
<Select
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_enabled: v,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Вкл</SelectItem>
<SelectItem value="false">Выкл</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_enabled
</p>
</div>
<SelectField
label="Авто-очистка включена"
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
placeholder="Выберите"
description="runtime_logs_auto_enabled"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_enabled: v,
}))
}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
<Input
@@ -308,31 +286,20 @@ function TenantSettingsComponent() {
runtime_logs_auto_schedule
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>Режим очистки</Label>
<Select
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_mode: v,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="truncate">truncate обнулить</SelectItem>
<SelectItem value="delete">delete удалить файл</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_mode
</p>
</div>
<SelectField
label="Режим очистки"
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
placeholder="Выберите"
description="runtime_logs_auto_mode"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_mode: v,
}))
}
/>
<div className="md:col-span-2">
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save />
@@ -366,22 +333,10 @@ function TenantSettingsComponent() {
onRetry={() => settingsQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Ключ</TableHead>
<TableHead>Значение</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.key}</TableCell>
<TableCell className="font-mono text-xs">{row.value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<SettingsKvGrid
items={items}
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
/>
)}
</QueryState>
</CardContent>
+2
View File
@@ -356,6 +356,8 @@ export type FirewallClient = {
last_apply_at?: string | null
last_apply_status?: string
last_apply_prefix_count?: number
last_apply_packets_dropped?: number
last_apply_packets_accepted?: number
last_apply_source?: string
client_version?: string
created_at: string
File diff suppressed because one or more lines are too long
+58
View File
@@ -1605,6 +1605,14 @@ components:
type: string
last_apply_prefix_count:
type: integer
last_apply_packets_dropped:
type: integer
format: int64
description: Cumulative packets dropped by blocklist rule (from client kernel counter).
last_apply_packets_accepted:
type: integer
format: int64
description: Cumulative packets accepted past blocklist chain (nft counter accept rule).
client_version:
type: string
@@ -4472,6 +4480,31 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/clients/{id}/revoke:
post:
tags: [Firewall]
summary: Reject pending or revoke approved client
operationId: revokeFirewallClient
parameters:
- name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
responses:
"200":
description: Revoked
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [revoked]
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/rules:
get:
tags: [Firewall]
@@ -4522,6 +4555,31 @@ paths:
tags: [Firewall]
summary: Report last apply status
operationId: firewallApplyReport
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
status:
type: string
error:
type: string
prefix_count:
type: integer
ip_count:
type: integer
packets_dropped:
type: integer
format: int64
packets_accepted:
type: integer
format: int64
kernel_method:
type: string
source:
type: string
responses:
"200":
description: OK
+158 -17
View File
@@ -69,6 +69,10 @@ try_fetch_blocklist() {
parse_blocklist_file() {
local f="$1"
if [[ ! -s "$f" ]]; then
log "blocklist file empty: $f"
return 1
fi
if command -v jq >/dev/null 2>&1; then
HASH=$(jq -r '.hash // empty' "$f")
TOTAL=$(jq -r '.total // 0' "$f")
@@ -99,6 +103,35 @@ PY
return 0
}
nft_join_elements() {
local out="" p
for p in "$@"; do
if [[ -n "$out" ]]; then
out+=", "
fi
out+="$p"
done
printf '%s' "$out"
}
nft_add_v4_chunk() {
local table=$1 name=$2
shift 2
local joined
joined=$(nft_join_elements "$@")
if nft add element "$table" "$name" v4 "{ ${joined} }" 2>>"$LOG_FILE"; then
return 0
fi
log "nft batch add failed (chunk=$#), retrying one-by-one"
local p ok=0
for p in "$@"; do
if nft add element "$table" "$name" v4 "{ $p }" 2>>"$LOG_FILE"; then
ok=$((ok + 1))
fi
done
[[ "$ok" -gt 0 ]]
}
if ! try_fetch_blocklist; then
log "all endpoints failed"
exit 1
@@ -108,13 +141,125 @@ HASH=""
TOTAL=0
PREFIXES=()
parse_blocklist_file "$PREFIX_FILE"
log "blocklist bytes=$(wc -c <"$PREFIX_FILE" | tr -d ' ') parsed=${#PREFIXES[@]} api_total=${TOTAL:-0}"
if [[ -z "${TOTAL// }" ]]; then
TOTAL=${#PREFIXES[@]}
fi
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
KERNEL_METHOD=""
APPLIED_V4=0
count_ipv4_prefixes() {
local n=0 p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
n=$((n + 1))
done
APPLIED_V4=$n
}
nft_rule_packets() {
local line=$1
if [[ "$line" =~ counter[[:space:]]+packets[[:space:]]+([0-9]+) ]]; then
echo "${BASH_REMATCH[1]}"
else
echo 0
fi
}
ensure_nft_counters() {
local table=inet name=evobgp_blocklist
nft list chain "$table" "$name" input >/dev/null 2>&1 || return 0
local drop_line
drop_line=$(nft -a list chain "$table" "$name" input 2>/dev/null | grep 'ip saddr @v4' | grep drop | head -1 || true)
if [[ -n "$drop_line" && "$drop_line" != *counter* ]]; then
local handle
handle=$(echo "$drop_line" | sed -n 's/.*# handle \([0-9]\+\).*/\1/p')
if [[ -n "$handle" ]]; then
nft delete rule "$table" "$name" input handle "$handle" 2>>"$LOG_FILE" || true
drop_line=""
fi
fi
if [[ -z "$drop_line" ]]; then
nft add rule "$table" "$name" input ip saddr @v4 counter drop
fi
if ! nft list chain "$table" "$name" input 2>/dev/null | grep -qE '[[:space:]]counter[[:space:]]+accept'; then
nft add rule "$table" "$name" input counter accept
fi
}
collect_nft_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local line pkts
while IFS= read -r line; do
if [[ "$line" == *"ip saddr @v4"* && "$line" == *drop* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_DROPPED=$pkts
elif [[ "$line" == *counter* && "$line" == *accept* && "$line" != *@v4* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_ACCEPTED=$pkts
fi
done < <(nft list chain inet evobgp_blocklist input 2>/dev/null || true)
}
collect_ipset_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local pkts
pkts=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/match-set evobgp_blocklist_v4/ {print $1; exit}')
[[ "$pkts" =~ ^[0-9]+$ ]] && PACKETS_DROPPED=$pkts
}
collect_packet_stats() {
case "${KERNEL_METHOD:-$BACKEND}" in
nft)
ensure_nft_counters
collect_nft_packet_stats
;;
ipset)
collect_ipset_packet_stats
;;
iptables)
PACKETS_DROPPED=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/DROP/ {s+=$1} END {print s+0}')
PACKETS_ACCEPTED=0
;;
*)
if command -v nft >/dev/null 2>&1 && nft list chain inet evobgp_blocklist input >/dev/null 2>&1; then
KERNEL_METHOD=nft
ensure_nft_counters
collect_nft_packet_stats
elif iptables -L INPUT -v -n -x 2>/dev/null | grep -q 'evobgp_blocklist_v4'; then
KERNEL_METHOD=ipset
collect_ipset_packet_stats
fi
;;
esac
}
send_client_reports() {
collect_packet_stats
local km="${KERNEL_METHOD:-$BACKEND}"
local report
report=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"packets_dropped":%s,"packets_accepted":%s,"source":"cp","kernel_method":"%s"}' \
"${TOTAL:-0}" "${APPLIED_V4:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "$km")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$report" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
}
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
log "unchanged hash $HASH — skip kernel apply"
count_ipv4_prefixes
log "unchanged hash $HASH — skip kernel apply (ipv4=${APPLIED_V4})"
send_client_reports
exit 0
fi
@@ -135,24 +280,26 @@ apply_nft() {
if ((${#v4[@]})); then
local batch=()
local chunk=128
local n
local chunk=64
for p in "${v4[@]}"; do
batch+=("$p")
if ((${#batch[@]} >= chunk)); then
nft add element "$table" "$name" v4 "{ $(IFS=,; echo "${batch[*]}") }"
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft chunk add partial failure"
batch=()
fi
done
if ((${#batch[@]})); then
nft add element "$table" "$name" v4 "{ $(IFS=,; echo "${batch[*]}") }"
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft tail chunk add partial failure"
fi
fi
nft list chain "$table" "$name" input >/dev/null 2>&1 || {
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
nft add rule "$table" "$name" input ip saddr @v4 drop
nft add rule "$table" "$name" input ip saddr @v4 counter drop
nft add rule "$table" "$name" input counter accept
}
ensure_nft_counters
KERNEL_METHOD=nft
APPLIED_V4=${#v4[@]}
}
@@ -169,6 +316,7 @@ apply_ipset() {
done
iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \
iptables -I INPUT -m set --match-set "$set" src -j DROP
KERNEL_METHOD=ipset
APPLIED_V4=$n
}
@@ -181,6 +329,7 @@ apply_iptables_only() {
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
n=$((n + 1))
done
KERNEL_METHOD=iptables
APPLIED_V4=$n
}
@@ -194,9 +343,11 @@ clear_block() {
iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;;
esac
APPLIED_V4=0
KERNEL_METHOD="${BACKEND:-auto}"
}
APPLIED_V4=0
KERNEL_METHOD=""
if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
clear_block
log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND"
@@ -211,14 +362,4 @@ else
fi
echo "$HASH" >"$HASH_FILE"
REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"source":"cp"}' "${TOTAL:-0}" "${APPLIED_V4:-0}")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$REPORT" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
send_client_reports
+13 -8
View File
@@ -450,13 +450,15 @@ func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Reques
return
}
var body struct {
Status string `json:"status"`
Error string `json:"error"`
PrefixCount int `json:"prefix_count"`
IPCount int `json:"ip_count"`
Version string `json:"version"`
KernelMethod string `json:"kernel_method"`
Source string `json:"source"`
Status string `json:"status"`
Error string `json:"error"`
PrefixCount int `json:"prefix_count"`
IPCount int `json:"ip_count"`
PacketsDropped int64 `json:"packets_dropped"`
PacketsAccepted int64 `json:"packets_accepted"`
Version string `json:"version"`
KernelMethod string `json:"kernel_method"`
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
@@ -466,7 +468,10 @@ func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Reques
if src == "" {
src = "cp"
}
_ = s.store.TouchFirewallClientLastApply(a.APIKeyID, src, body.Status, body.Error, body.PrefixCount, body.IPCount)
_ = s.store.TouchFirewallClientLastApply(
a.APIKeyID, src, body.Status, body.Error,
body.PrefixCount, body.IPCount, body.PacketsDropped, body.PacketsAccepted,
)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
+90
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -94,6 +95,27 @@ func TestFirewallEnrollAndBlocklist(t *testing.T) {
if total, _ := bl["total"].(float64); total != 0 {
t.Fatalf("accept-only want empty blocklist, total=%v", total)
}
reportBody := `{"status":"ok","prefix_count":0,"ip_count":0,"packets_dropped":42,"packets_accepted":1000,"source":"cp","kernel_method":"nft"}`
reqReport, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/apply-report", strings.NewReader(reportBody))
reqReport.Header.Set("Authorization", "Bearer "+tok)
reqReport.Header.Set("Content-Type", "application/json")
respReport, err := client.Do(reqReport)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respReport.Body.Close() }()
if respReport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respReport.Body)
t.Fatalf("apply-report status=%d body=%s", respReport.StatusCode, b)
}
gotClient, err := srv.Store().GetFirewallClient(tenant, clientID)
if err != nil {
t.Fatal(err)
}
if gotClient.LastApplyPacketsDropped != 42 || gotClient.LastApplyPacketsAccepted != 1000 {
t.Fatalf("packet stats dropped=%d accepted=%d", gotClient.LastApplyPacketsDropped, gotClient.LastApplyPacketsAccepted)
}
}
func TestFirewallEnrollBadSeed(t *testing.T) {
@@ -205,6 +227,74 @@ func TestFirewallInstallContext(t *testing.T) {
}
}
func TestFirewallDeletePendingClient(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
tok := "evobgp_fw_revoketest123456789012345678901"
enrollBody := `{"name":"reject-me","hostname":"test.local","client_token":"` + tok + `","client_version":"test/1"}`
reqEnroll, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(enrollBody))
reqEnroll.Header.Set("Content-Type", "application/json")
reqEnroll.Header.Set("X-EvoBGP-Seed", testBundleSeed)
respEnroll, err := client.Do(reqEnroll)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respEnroll.Body.Close() }()
if respEnroll.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(respEnroll.Body)
t.Fatalf("enroll status=%d body=%s", respEnroll.StatusCode, b)
}
var enroll map[string]any
if err := json.NewDecoder(respEnroll.Body).Decode(&enroll); err != nil {
t.Fatal(err)
}
clientID, _ := enroll["client_id"].(string)
if clientID == "" {
t.Fatal("missing client_id")
}
reqDelete, _ := http.NewRequest(http.MethodDelete, ts.URL+"/v1/firewall/clients/"+clientID, nil)
reqDelete.Header.Set("Authorization", "Bearer opkey")
respDelete, err := client.Do(reqDelete)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respDelete.Body.Close() }()
if respDelete.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(respDelete.Body)
t.Fatalf("delete status=%d body=%s", respDelete.StatusCode, b)
}
_, err = srv.Store().GetFirewallClient(tenant, clientID)
if err == nil {
t.Fatal("client should be deleted")
}
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("delete err=%v", err)
}
reqBlock, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
reqBlock.Header.Set("Authorization", "Bearer "+tok)
respBlock, err := client.Do(reqBlock)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respBlock.Body.Close() }()
if respBlock.StatusCode != http.StatusUnauthorized {
t.Fatalf("deleted blocklist want 401 got %d", respBlock.StatusCode)
}
}
func TestFirewallTokenHashMatchesAuthkey(t *testing.T) {
tok := "evobgp_fw_sample"
h := authkey.HashToken(tok)
+20 -9
View File
@@ -17,7 +17,9 @@ const firewallClientSelectCols = `
id, name, COALESCE(hostname, ''), token_prefix, status,
last_seen_at, COALESCE(last_seen_at_source, ''), COALESCE(last_seen_ip, ''),
last_apply_at, COALESCE(last_apply_status, ''), COALESCE(last_apply_error, ''),
COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0), COALESCE(last_apply_source, ''),
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`
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
@@ -167,12 +169,13 @@ func (p *Postgres) TouchFirewallClientLastSeen(id, source, clientIP, clientVersi
return err
}
func (p *Postgres) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
func (p *Postgres) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error {
ctx := context.Background()
_, err := p.pool.Exec(ctx, `
UPDATE firewall_client SET last_apply_at=now(), last_apply_source=$2, last_apply_status=$3,
last_apply_error=$4, last_apply_prefix_count=$5, last_apply_ip_count=$6
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(status), strings.TrimSpace(errMsg), prefixCount, ipCount)
last_apply_error=$4, last_apply_prefix_count=$5, last_apply_ip_count=$6,
last_apply_packets_dropped=$7, last_apply_packets_accepted=$8
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(status), strings.TrimSpace(errMsg), prefixCount, ipCount, packetsDropped, packetsAccepted)
return err
}
@@ -435,16 +438,17 @@ func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient,
var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
if err := scan(
&c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &c.LastApplySource,
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
@@ -452,19 +456,20 @@ func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
if err := scan(
&c.TenantID, &c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &c.LastApplySource,
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int) *store.FirewallClient {
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
c.LastSeenAt = lastSeen
c.LastApplyAt = lastApply
c.ApprovedAt = approved
@@ -478,6 +483,12 @@ func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, appr
if ipCount != nil {
c.LastApplyIPCount = *ipCount
}
if packetsDropped != nil {
c.LastApplyPacketsDropped = *packetsDropped
}
if packetsAccepted != nil {
c.LastApplyPacketsAccepted = *packetsAccepted
}
return c
}
+1 -1
View File
@@ -142,7 +142,7 @@ type Backend interface {
DeleteFirewallClient(tenantID, id string) error
LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error)
TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error
TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error
TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error
ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error)
ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error)
+22 -20
View File
@@ -7,26 +7,28 @@ import (
// FirewallClient is a Linux blocklist sync client enrolled via seed.
type FirewallClient struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Hostname string `json:"hostname,omitempty"`
TokenPrefix string `json:"token_prefix"`
Status string `json:"status"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LastSeenAtSource string `json:"last_seen_at_source,omitempty"`
LastSeenIP string `json:"last_seen_ip,omitempty"`
LastApplyAt *time.Time `json:"last_apply_at,omitempty"`
LastApplyStatus string `json:"last_apply_status,omitempty"`
LastApplyError string `json:"last_apply_error,omitempty"`
LastApplyPrefixCount int `json:"last_apply_prefix_count,omitempty"`
LastApplyIPCount int `json:"last_apply_ip_count,omitempty"`
LastApplySource string `json:"last_apply_source,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
CreatedAt time.Time `json:"created_at"`
ApprovedAt *time.Time `json:"approved_at,omitempty"`
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Hostname string `json:"hostname,omitempty"`
TokenPrefix string `json:"token_prefix"`
Status string `json:"status"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LastSeenAtSource string `json:"last_seen_at_source,omitempty"`
LastSeenIP string `json:"last_seen_ip,omitempty"`
LastApplyAt *time.Time `json:"last_apply_at,omitempty"`
LastApplyStatus string `json:"last_apply_status,omitempty"`
LastApplyError string `json:"last_apply_error,omitempty"`
LastApplyPrefixCount int `json:"last_apply_prefix_count,omitempty"`
LastApplyIPCount int `json:"last_apply_ip_count,omitempty"`
LastApplyPacketsDropped int64 `json:"last_apply_packets_dropped,omitempty"`
LastApplyPacketsAccepted int64 `json:"last_apply_packets_accepted,omitempty"`
LastApplySource string `json:"last_apply_source,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
CreatedAt time.Time `json:"created_at"`
ApprovedAt *time.Time `json:"approved_at,omitempty"`
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// FirewallClientCreate is input for enroll (token hash supplied by caller).
+3 -1
View File
@@ -171,7 +171,7 @@ func (m *Memory) TouchFirewallClientLastSeen(id, source, clientIP, clientVersion
return nil
}
func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
@@ -185,6 +185,8 @@ func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string,
rec.LastApplyError = strings.TrimSpace(errMsg)
rec.LastApplyPrefixCount = prefixCount
rec.LastApplyIPCount = ipCount
rec.LastApplyPacketsDropped = packetsDropped
rec.LastApplyPacketsAccepted = packetsAccepted
return nil
}
@@ -0,0 +1,3 @@
ALTER TABLE firewall_client
DROP COLUMN IF EXISTS last_apply_packets_dropped,
DROP COLUMN IF EXISTS last_apply_packets_accepted;
@@ -0,0 +1,3 @@
ALTER TABLE firewall_client
ADD COLUMN last_apply_packets_dropped BIGINT NOT NULL DEFAULT 0,
ADD COLUMN last_apply_packets_accepted BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
ALTER TABLE firewall_client DROP COLUMN last_apply_packets_dropped;
ALTER TABLE firewall_client DROP COLUMN last_apply_packets_accepted;
@@ -0,0 +1,2 @@
ALTER TABLE firewall_client ADD COLUMN last_apply_packets_dropped INTEGER NOT NULL DEFAULT 0;
ALTER TABLE firewall_client ADD COLUMN last_apply_packets_accepted INTEGER NOT NULL DEFAULT 0;
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@evobgp/ui/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+81
View File
@@ -0,0 +1,81 @@
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@evobgp/ui/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}
-6
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
@@ -111,15 +109,11 @@ function SelectLabel({
function SelectItem({
className,
children,
label,
...props
}: SelectPrimitive.Item.Props) {
const resolvedLabel = label ?? (typeof children === "string" ? children : undefined)
return (
<SelectPrimitive.Item
data-slot="select-item"
label={resolvedLabel}
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@evobgp/ui/lib/utils"
+158 -17
View File
@@ -69,6 +69,10 @@ try_fetch_blocklist() {
parse_blocklist_file() {
local f="$1"
if [[ ! -s "$f" ]]; then
log "blocklist file empty: $f"
return 1
fi
if command -v jq >/dev/null 2>&1; then
HASH=$(jq -r '.hash // empty' "$f")
TOTAL=$(jq -r '.total // 0' "$f")
@@ -99,6 +103,35 @@ PY
return 0
}
nft_join_elements() {
local out="" p
for p in "$@"; do
if [[ -n "$out" ]]; then
out+=", "
fi
out+="$p"
done
printf '%s' "$out"
}
nft_add_v4_chunk() {
local table=$1 name=$2
shift 2
local joined
joined=$(nft_join_elements "$@")
if nft add element "$table" "$name" v4 "{ ${joined} }" 2>>"$LOG_FILE"; then
return 0
fi
log "nft batch add failed (chunk=$#), retrying one-by-one"
local p ok=0
for p in "$@"; do
if nft add element "$table" "$name" v4 "{ $p }" 2>>"$LOG_FILE"; then
ok=$((ok + 1))
fi
done
[[ "$ok" -gt 0 ]]
}
if ! try_fetch_blocklist; then
log "all endpoints failed"
exit 1
@@ -108,13 +141,125 @@ HASH=""
TOTAL=0
PREFIXES=()
parse_blocklist_file "$PREFIX_FILE"
log "blocklist bytes=$(wc -c <"$PREFIX_FILE" | tr -d ' ') parsed=${#PREFIXES[@]} api_total=${TOTAL:-0}"
if [[ -z "${TOTAL// }" ]]; then
TOTAL=${#PREFIXES[@]}
fi
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
KERNEL_METHOD=""
APPLIED_V4=0
count_ipv4_prefixes() {
local n=0 p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
n=$((n + 1))
done
APPLIED_V4=$n
}
nft_rule_packets() {
local line=$1
if [[ "$line" =~ counter[[:space:]]+packets[[:space:]]+([0-9]+) ]]; then
echo "${BASH_REMATCH[1]}"
else
echo 0
fi
}
ensure_nft_counters() {
local table=inet name=evobgp_blocklist
nft list chain "$table" "$name" input >/dev/null 2>&1 || return 0
local drop_line
drop_line=$(nft -a list chain "$table" "$name" input 2>/dev/null | grep 'ip saddr @v4' | grep drop | head -1 || true)
if [[ -n "$drop_line" && "$drop_line" != *counter* ]]; then
local handle
handle=$(echo "$drop_line" | sed -n 's/.*# handle \([0-9]\+\).*/\1/p')
if [[ -n "$handle" ]]; then
nft delete rule "$table" "$name" input handle "$handle" 2>>"$LOG_FILE" || true
drop_line=""
fi
fi
if [[ -z "$drop_line" ]]; then
nft add rule "$table" "$name" input ip saddr @v4 counter drop
fi
if ! nft list chain "$table" "$name" input 2>/dev/null | grep -qE '[[:space:]]counter[[:space:]]+accept'; then
nft add rule "$table" "$name" input counter accept
fi
}
collect_nft_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local line pkts
while IFS= read -r line; do
if [[ "$line" == *"ip saddr @v4"* && "$line" == *drop* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_DROPPED=$pkts
elif [[ "$line" == *counter* && "$line" == *accept* && "$line" != *@v4* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_ACCEPTED=$pkts
fi
done < <(nft list chain inet evobgp_blocklist input 2>/dev/null || true)
}
collect_ipset_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local pkts
pkts=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/match-set evobgp_blocklist_v4/ {print $1; exit}')
[[ "$pkts" =~ ^[0-9]+$ ]] && PACKETS_DROPPED=$pkts
}
collect_packet_stats() {
case "${KERNEL_METHOD:-$BACKEND}" in
nft)
ensure_nft_counters
collect_nft_packet_stats
;;
ipset)
collect_ipset_packet_stats
;;
iptables)
PACKETS_DROPPED=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/DROP/ {s+=$1} END {print s+0}')
PACKETS_ACCEPTED=0
;;
*)
if command -v nft >/dev/null 2>&1 && nft list chain inet evobgp_blocklist input >/dev/null 2>&1; then
KERNEL_METHOD=nft
ensure_nft_counters
collect_nft_packet_stats
elif iptables -L INPUT -v -n -x 2>/dev/null | grep -q 'evobgp_blocklist_v4'; then
KERNEL_METHOD=ipset
collect_ipset_packet_stats
fi
;;
esac
}
send_client_reports() {
collect_packet_stats
local km="${KERNEL_METHOD:-$BACKEND}"
local report
report=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"packets_dropped":%s,"packets_accepted":%s,"source":"cp","kernel_method":"%s"}' \
"${TOTAL:-0}" "${APPLIED_V4:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "$km")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$report" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
}
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
log "unchanged hash $HASH — skip kernel apply"
count_ipv4_prefixes
log "unchanged hash $HASH — skip kernel apply (ipv4=${APPLIED_V4})"
send_client_reports
exit 0
fi
@@ -135,24 +280,26 @@ apply_nft() {
if ((${#v4[@]})); then
local batch=()
local chunk=128
local n
local chunk=64
for p in "${v4[@]}"; do
batch+=("$p")
if ((${#batch[@]} >= chunk)); then
nft add element "$table" "$name" v4 "{ $(IFS=,; echo "${batch[*]}") }"
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft chunk add partial failure"
batch=()
fi
done
if ((${#batch[@]})); then
nft add element "$table" "$name" v4 "{ $(IFS=,; echo "${batch[*]}") }"
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft tail chunk add partial failure"
fi
fi
nft list chain "$table" "$name" input >/dev/null 2>&1 || {
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
nft add rule "$table" "$name" input ip saddr @v4 drop
nft add rule "$table" "$name" input ip saddr @v4 counter drop
nft add rule "$table" "$name" input counter accept
}
ensure_nft_counters
KERNEL_METHOD=nft
APPLIED_V4=${#v4[@]}
}
@@ -169,6 +316,7 @@ apply_ipset() {
done
iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \
iptables -I INPUT -m set --match-set "$set" src -j DROP
KERNEL_METHOD=ipset
APPLIED_V4=$n
}
@@ -181,6 +329,7 @@ apply_iptables_only() {
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
n=$((n + 1))
done
KERNEL_METHOD=iptables
APPLIED_V4=$n
}
@@ -194,9 +343,11 @@ clear_block() {
iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;;
esac
APPLIED_V4=0
KERNEL_METHOD="${BACKEND:-auto}"
}
APPLIED_V4=0
KERNEL_METHOD=""
if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
clear_block
log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND"
@@ -211,14 +362,4 @@ else
fi
echo "$HASH" >"$HASH_FILE"
REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"source":"cp"}' "${TOTAL:-0}" "${APPLIED_V4:-0}")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$REPORT" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
send_client_reports