Compare commits

...
5 Commits
Author SHA1 Message Date
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
50 changed files with 3132 additions and 1458 deletions
@@ -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="Нет ключей"
/>
)
}
@@ -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 { FrameFooter } from '@/components/reui/frame'
export function DashboardQuickActions() {
return (
<FrameFooter className="flex flex-wrap gap-2">
<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>
</FrameFooter>
)
}
@@ -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,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}
/>
)
}
@@ -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="Нет ревизий"
/>
)
}
+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="Нет модулей"
/>
)
}
@@ -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}
/>
)
}
+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,
}
}
+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 : 'Не удалось удалить'),
})
}
+73 -194
View File
@@ -1,4 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import {
Boxes,
@@ -6,26 +6,29 @@ import {
Clock,
GitBranch,
Info,
Network,
Play,
Plus,
Radio,
RefreshCw,
Activity,
Share2,
Tags,
Gauge,
XCircle,
} from 'lucide-react'
import { Link } from '@tanstack/react-router'
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 { Skeleton } from '@evobgp/ui/components/skeleton'
import { DashboardNetworkPanel } from '@/components/dashboard/dashboard-network-panel'
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 {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
@@ -46,6 +49,7 @@ export const Route = createFileRoute('/_auth/dashboard')({
})
function DashboardComponent() {
const navigate = useNavigate()
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const results = useQueries({
@@ -95,7 +99,7 @@ function DashboardComponent() {
value: initialLoading ? '—' : String(modules.length),
icon: <Boxes className="size-4" />,
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
onClick: () => window.location.assign('/modules'),
onClick: () => navigate({ to: '/modules' }),
},
{
label: 'Пиры',
@@ -104,7 +108,7 @@ function DashboardComponent() {
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'),
onClick: () => navigate({ to: '/network', search: { tab: 'peers' } }),
},
{
label: 'Спикеры',
@@ -112,26 +116,29 @@ function DashboardComponent() {
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'),
onClick: () => navigate({ to: '/network', search: { tab: 'overview' } }),
},
{
label: 'Ревизии',
value: initialLoading ? '—' : String(revisions.length),
icon: <Activity className="size-4" />,
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
onClick: () => window.location.assign('/operations'),
onClick: () => navigate({ to: '/operations', search: { tab: 'revisions' } }),
},
{
label: 'Активных задач',
value: initialLoading ? '—' : String(running),
icon: <Clock className="size-4" />,
hint: 'queued и running',
onClick: () => window.location.assign('/operations?tab=jobs'),
onClick: () => navigate({ to: '/operations', search: { tab: 'jobs' } }),
},
]
const activityLoading =
refreshing && jobs.length === 0 && revisions.length === 0 && peers.length === 0 && speakers.length === 0
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-5">
<PageHeader
title="Обзор"
description={
@@ -162,46 +169,61 @@ function DashboardComponent() {
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
/>
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} />}
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} className="gap-3" />}
<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} />
<Frame spacing="sm" className="h-full">
<FramePanel className="flex h-full flex-col p-0">
<FrameHeader className="border-b">
<FrameTitle>Недавние задачи</FrameTitle>
<FrameDescription>Последние фоновые операции</FrameDescription>
</FrameHeader>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentJobsGrid jobs={jobs} nameById={nameById} isLoading={refreshing} />
)}
</FramePanel>
</Frame>
<Frame spacing="sm" className="h-full">
<FramePanel className="flex h-full flex-col p-0">
<FrameHeader className="border-b">
<FrameTitle>Последние ревизии</FrameTitle>
<FrameDescription>История конфигураций</FrameDescription>
</FrameHeader>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
)}
</FramePanel>
</Frame>
<Frame spacing="sm" className="h-full">
<FramePanel className="flex h-full flex-col p-0">
<FrameHeader className="border-b">
<FrameTitle>Состояние сети</FrameTitle>
<FrameDescription>BGP-сессии и спикеры</FrameDescription>
</FrameHeader>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardNetworkPanel peers={peers} speakers={speakers} />
)}
</FramePanel>
</Frame>
</div>
<Card>
<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>
</Card>
<Frame spacing="sm">
<FramePanel className="p-0">
<FrameHeader className="border-b">
<FrameTitle>Быстрые действия</FrameTitle>
<FrameDescription>Частые переходы к настройке и деплою</FrameDescription>
</FrameHeader>
<DashboardQuickActions />
</FramePanel>
</Frame>
</div>
)
}
@@ -255,146 +277,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>
)
}
+3 -88
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, Gauge, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
@@ -8,15 +8,8 @@ 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 { 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'
@@ -147,7 +140,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>
@@ -342,84 +335,6 @@ function overallHint(input: OverallInput): string {
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 (
+41 -123
View File
@@ -5,17 +5,10 @@ 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 { 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'
@@ -115,47 +108,47 @@ function NetworkComponent() {
</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 +189,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>
)
}
+42 -183
View File
@@ -15,15 +15,10 @@ import {
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 { DataGridCard } from '@/components/data-grid-shell'
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'
@@ -33,7 +28,6 @@ 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,
@@ -177,24 +171,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 +197,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('')
+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>
)
}
+6 -24
View File
@@ -16,15 +16,9 @@ import {
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 { 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'
@@ -366,22 +360,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
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/data-grid-shell.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-quick-actions.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/settings-kv-grid.tsx","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
+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
+121 -13
View File
@@ -147,8 +147,119 @@ 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
@@ -184,8 +295,11 @@ apply_nft() {
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[@]}
}
@@ -202,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
}
@@ -214,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
}
@@ -227,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"
@@ -244,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;
+121 -13
View File
@@ -147,8 +147,119 @@ 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
@@ -184,8 +295,11 @@ apply_nft() {
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[@]}
}
@@ -202,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
}
@@ -214,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
}
@@ -227,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"
@@ -244,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