feat(httpapi): remove firewall HTTP/UI after EvoFirewall cutover
CI / changes (push) Successful in 4s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 51s
CI / go (push) Successful in 59s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m11s

Все /v1/firewall/* отвечают 410 Gone; UI и docs указывают на EvoFirewall.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 19:51:08 +07:00
co-authored by Cursor
parent b28ad88b22
commit c1132cbe19
13 changed files with 58 additions and 1989 deletions
@@ -1,195 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } 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 { useClientDataGrid } from '@/hooks/use-client-data-grid'
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 }) => (
<DataGridPrimaryCell
title={row.original.name}
subtitle={row.original.hostname || row.original.token_prefix}
accent="primary"
/>
),
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="Последняя активность" />,
cell: ({ row }) => (
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
),
sortingFn: (a, b) => {
const av = a.original.last_seen_at ?? ''
const bv = b.original.last_seen_at ?? ''
return av.localeCompare(bv)
},
meta: { headerTitle: 'Последняя активность' },
},
{
id: 'apply',
enableSorting: false,
header: 'Применение',
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: 'Применение' },
},
{
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, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: clients,
columns,
getSearchText: (row) =>
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск клиентов…"
/>
)
}
@@ -1,101 +0,0 @@
import { useEffect, useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { useCreateFirewallRule } from '@/queries/firewall'
import type { BgpCommunity } from '@/types/api'
const FIREWALL_ACTION_ITEMS = [
{ value: 'block', label: 'block' },
{ value: 'accept', label: 'accept' },
] as const
interface FirewallRuleCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
communities: BgpCommunity[]
}
export function FirewallRuleCreateDialog({
open,
onOpenChange,
communities,
}: FirewallRuleCreateDialogProps) {
const createMutation = useCreateFirewallRule()
const [action, setAction] = useState<'block' | 'accept'>('block')
const [communityId, setCommunityId] = useState<string | null>(null)
const [comment, setComment] = useState('')
useEffect(() => {
if (!open) return
setAction('block')
setCommunityId(null)
setComment('')
}, [open])
async function save() {
try {
await createMutation.mutateAsync({
scope: 'tenant',
action,
community_id: communityId,
comment: comment.trim(),
})
onOpenChange(false)
} catch {
// toast handled in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новое правило"
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
Добавить
</LoadingButton>
</>
}
>
<SelectField
id="fw-rule-action"
label="Действие"
items={[...FIREWALL_ACTION_ITEMS]}
value={action}
placeholder="Выберите действие"
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
/>
<CommunitySelect
id="fw-rule-community"
label="Community"
value={communityId}
onValueChange={setCommunityId}
communities={communities}
nullable
placeholder="Все communities"
/>
<div className="flex flex-col gap-2">
<Label htmlFor="fw-rule-comment">Комментарий</Label>
<Input
id="fw-rule-comment"
placeholder="Комментарий"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
</FormDrawer>
)
}
@@ -1,122 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridSection } 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 { useClientDataGrid } from '@/hooks/use-client-data-grid'
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, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: rules,
columns,
getSearchText: (row) =>
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск правил…"
/>
)
}
@@ -9,7 +9,6 @@ import {
BookText,
KeyRound,
ServerCog,
Shield,
Search,
} from 'lucide-react'
@@ -90,7 +89,6 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
{ to: '/firewall', label: 'Файрвол', icon: Shield, description: 'Клиенты и правила' },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
],
-2
View File
@@ -269,7 +269,6 @@ export function permissionForPath(pathname: string): string | null {
if (pathname.startsWith('/network')) return 'bgp:network:read'
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
if (pathname.startsWith('/firewall')) return 'bgp:firewall:read'
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
if (pathname.startsWith('/access')) return 'bgp:access:admin'
@@ -289,7 +288,6 @@ export function firstAllowedPath(): string {
'/network',
'/directories',
'/operations',
'/firewall',
'/schedule',
'/monitoring',
'/access',
-102
View File
@@ -1,102 +0,0 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON } from '@/lib/api-client'
import type {
FirewallClient,
FirewallClientsResponse,
FirewallInstallContext,
FirewallRule,
FirewallRulesResponse,
} from '@/types/api'
export const firewallKeys = {
all: ['firewall'] as const,
clients: () => [...firewallKeys.all, 'clients'] as const,
installContext: () => [...firewallKeys.all, 'install-context'] as const,
rules: (scope: string, clientId?: string) =>
[...firewallKeys.all, 'rules', scope, clientId ?? ''] as const,
}
export function firewallInstallContextQueryOptions() {
return queryOptions<FirewallInstallContext>({
queryKey: firewallKeys.installContext(),
queryFn: () => apiJSON<FirewallInstallContext>('/v1/firewall/install-context'),
staleTime: 60_000,
retry: false,
})
}
export function firewallClientsQueryOptions() {
return queryOptions<FirewallClientsResponse>({
queryKey: firewallKeys.clients(),
queryFn: () => apiJSON<FirewallClientsResponse>('/v1/firewall/clients'),
staleTime: 15_000,
})
}
export function firewallRulesQueryOptions(scope: 'tenant' | 'client', clientId?: string) {
const qs =
scope === 'client' && clientId
? `?scope=client&client_id=${encodeURIComponent(clientId)}`
: '?scope=tenant'
return queryOptions<FirewallRulesResponse>({
queryKey: firewallKeys.rules(scope, clientId),
queryFn: () => apiJSON<FirewallRulesResponse>(`/v1/firewall/rules${qs}`),
staleTime: 15_000,
})
}
export function useApproveFirewallClient() {
const qc = useQueryClient()
return useMutation({
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 : 'Не удалось удалить'),
})
}
export function useCreateFirewallRule() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiJSON<FirewallRule>('/v1/firewall/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
onSuccess: () => {
toast.success('Правило добавлено')
void qc.invalidateQueries({ queryKey: firewallKeys.all })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось добавить правило'),
})
}
export function useDeleteFirewallRule() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<void>(`/v1/firewall/rules/${id}`, { method: 'DELETE' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: firewallKeys.all })
},
})
}
-276
View File
@@ -1,276 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Copy, Plus, RefreshCw, Shield } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
import { FirewallRuleCreateDialog } from '@/components/firewall/firewall-rule-create-dialog'
import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
import {
firewallClientsQueryOptions,
firewallInstallContextQueryOptions,
firewallRulesQueryOptions,
useApproveFirewallClient,
useDeleteFirewallClient,
useDeleteFirewallRule,
} from '@/queries/firewall'
function httpsOrigin(origin: string): string {
try {
const u = new URL(origin)
u.protocol = 'https:'
return u.origin
} catch {
return origin.replace(/^http:/i, 'https:')
}
}
export const Route = createFileRoute('/_auth/firewall')({
component: FirewallPage,
})
function FirewallPage() {
const installCtxQ = useQuery(firewallInstallContextQueryOptions())
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const clientsQ = useQuery(firewallClientsQueryOptions())
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient()
const deleteClient = useDeleteFirewallClient()
const deleteRule = useDeleteFirewallRule()
const installCtx = installCtxQ.data
const [clientName, setClientName] = useState('web-01')
const [cpUrl, setCpUrl] = useState(() =>
typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com',
)
const [seed, setSeed] = useState('')
const [createRuleOpen, setCreateRuleOpen] = useState(false)
useEffect(() => {
if (installCtx?.suggested_cp_url) {
setCpUrl(httpsOrigin(installCtx.suggested_cp_url))
}
if (installCtx?.bundle_seed) {
setSeed(installCtx.bundle_seed)
}
}, [installCtx?.bundle_seed, installCtx?.suggested_cp_url])
const communities = communitiesQ.data?.items ?? []
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(() => {
const s = seed.trim() || '<bundle_seed_hex>'
return `curl -fsSL ${cpUrl.replace(/\/$/, '')}/v1/firewall/install.sh | \\
EVOBGP_CP_URL=${cpUrl.replace(/\/$/, '')} \\
EVOBGP_SEED=${s} \\
EVOBGP_CLIENT_NAME="${clientName}" \\
bash`
}, [clientName, cpUrl, seed])
async function copyInstall() {
if (!seed.trim()) {
toast.error(
installCtx?.bundle_seed_configured === false
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
: 'Seed бандла недоступен (нужна роль оператора)',
)
return
}
try {
await navigator.clipboard.writeText(installCmd)
toast.success('Команда скопирована')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Файрвол: blocklist"
description="Linux-серверы: синхронизация CIDR по policy block/accept"
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void clientsQ.refetch()
void rulesQ.refetch()
}}
disabled={clientsQ.isFetching}
>
<RefreshCw className={clientsQ.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<PanelCard
title={
<span className="flex items-center gap-2">
<Shield className="size-4" />
Установка на сервер
</span>
}
description="Команда для root на целевом Linux (bash, curl). После регистрации — одобрите клиента во вкладке «Запросы»."
contentClassName="flex flex-col gap-4 py-4"
>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="fw-name">Имя сервера</Label>
<Input id="fw-name" value={clientName} onChange={(e) => setClientName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="fw-url">URL API</Label>
<Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="fw-seed">Seed бандла</Label>
<Input
id="fw-seed"
type="password"
readOnly
placeholder="EVOBGP_BUNDLE_SEED_HEX"
value={seed}
className="font-mono text-xs"
/>
<p className="text-muted-foreground text-xs">
{installCtxQ.isLoading
? 'Загрузка с плоскости управления…'
: installCtx?.bundle_seed_configured
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — регистрация невозможна'}
</p>
</div>
</div>
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre>
<Button variant="outline" size="sm" className="w-fit" onClick={copyInstall}>
<Copy />
Копировать команду
</Button>
</PanelCard>
<BadgeTabs
defaultValue="clients"
items={[
{ value: 'clients', label: 'Клиенты', count: activeClients.length },
{ value: 'rules', label: 'Правила', count: rules.length, badgeVariant: 'info-light' },
{
value: 'requests',
label: 'Запросы',
count: pending.length,
badgeVariant: pending.length > 0 ? 'warning-light' : 'primary-light',
},
]}
>
<TabsContent value="clients" className="mt-0">
<DataGridCard title="Клиенты" description="Активные Linux-серверы с синхронизацией списка блокировок">
<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>
</DataGridCard>
</TabsContent>
<TabsContent value="rules" className="mt-0">
<DataGridCard
title="Правила"
actions={
<Button size="sm" type="button" onClick={() => setCreateRuleOpen(true)}>
<Plus />
Добавить правило
</Button>
}
>
<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>
</DataGridCard>
<FirewallRuleCreateDialog
open={createRuleOpen}
onOpenChange={setCreateRuleOpen}
communities={communities}
/>
</TabsContent>
<TabsContent value="requests" className="mt-0">
<DataGridCard
title="Запросы"
description="Запросы на регистрацию — одобрите или отклоните новые клиенты"
>
<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="Нет ожидающих запросов"
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
</BadgeTabs>
</div>
)
}
+2 -38
View File
@@ -375,41 +375,5 @@ export type AsyncJobAccepted = {
job_id: string
}
// ---- Firewall blocklist ----
export type FirewallClient = {
id: string
name: string
hostname?: string
token_prefix: string
status: 'pending' | 'approved' | 'revoked'
last_seen_at?: string | null
last_seen_at_source?: string
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
}
export type FirewallClientsResponse = { items: FirewallClient[] }
export type FirewallRule = {
id: string
client_id?: string | null
priority: number
action: 'block' | 'accept'
community_id?: string | null
comment?: string
}
export type FirewallRulesResponse = { items: FirewallRule[] }
export type FirewallInstallContext = {
bundle_seed: string
bundle_seed_configured: boolean
suggested_cp_url: string
install_sh_url: string
}
// Firewall blocklist types removed — the firewall subsystem moved to the standalone
// EvoFirewall service. See docs/firewall.md.