- lib/format.ts: ru-RU форматтеры дат/чисел в одном месте — убраны дубли из 8 компонентов (agent-card, fleet-grid, blocked-ips/ports, facts, lifecycle, host-firewall, lists-columns, system-monitor) - lib/nav.ts: единый конфиг маршрутов — sidebar, ⌘K-поиск и breadcrumbs рендерятся из одного источника (было 3 расходящихся копии) - lib/permissions.ts + useCan: клиентский RBAC — кнопки создания/подтверждения скрываются без fw:*:write/admin (сервер остаётся авторитетным) - удалён мёртвый код ~2500 строк: stepper, data-grid dnd/virtual/visibility варианты, settings-shell
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import {
|
|
getCoreRowModel,
|
|
useReactTable,
|
|
type ColumnDef,
|
|
} from '@tanstack/react-table'
|
|
import { ShieldIcon } from 'lucide-react'
|
|
import {
|
|
Frame,
|
|
FrameDescription,
|
|
FrameHeader,
|
|
FramePanel,
|
|
FrameTitle,
|
|
} from '@/components/reui/frame'
|
|
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
|
import { Badge } from '@/components/reui/badge'
|
|
import { EmptyState } from '@/components/empty-state'
|
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
|
import {
|
|
agentHostFirewallQueryOptions,
|
|
type HostFwRuleDto,
|
|
type HostListenerDto,
|
|
} from '@/queries'
|
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@evofw/ui/components/select'
|
|
import { TabsContent } from '@evofw/ui/components/tabs'
|
|
import { formatDateTime } from '@/lib/format'
|
|
|
|
/**
|
|
* Observed host firewall + listeners (Linux).
|
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
|
* · https://reui.io/preview/base/empty-state-12
|
|
*/
|
|
|
|
type AgentHostFirewallProps = {
|
|
agentId: string
|
|
}
|
|
|
|
export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
|
|
const q = useQuery(agentHostFirewallQueryOptions(agentId))
|
|
const [tab, setTab] = useState('rules')
|
|
const [ownership, setOwnership] = useState<'all' | 'evofw' | 'foreign'>('all')
|
|
const [backend, setBackend] = useState<string>('all')
|
|
|
|
const rules = useMemo(() => {
|
|
let items = q.data?.rules ?? []
|
|
if (ownership !== 'all') {
|
|
items = items.filter((r) => r.ownership === ownership)
|
|
}
|
|
if (backend !== 'all') {
|
|
items = items.filter((r) => r.backend === backend)
|
|
}
|
|
return items
|
|
}, [q.data?.rules, ownership, backend])
|
|
|
|
const listeners = q.data?.listeners ?? []
|
|
const backends = useMemo(() => {
|
|
const s = new Set((q.data?.rules ?? []).map((r) => r.backend))
|
|
return Array.from(s).sort()
|
|
}, [q.data?.rules])
|
|
|
|
const ruleCols = useMemo<ColumnDef<HostFwRuleDto>[]>(
|
|
() => [
|
|
{
|
|
id: 'ownership',
|
|
accessorKey: 'ownership',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Owner" />
|
|
),
|
|
cell: ({ row }) =>
|
|
row.original.ownership === 'evofw' ? (
|
|
<Badge variant="success" size="sm">
|
|
EvoFW
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="secondary" size="sm">
|
|
foreign
|
|
</Badge>
|
|
),
|
|
meta: { headerTitle: 'Owner' },
|
|
},
|
|
{
|
|
accessorKey: 'backend',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Backend" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="font-mono text-xs">{row.original.backend}</span>
|
|
),
|
|
},
|
|
{
|
|
id: 'chain',
|
|
accessorFn: (r) => r.chain || r.table || '—',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Chain" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="font-mono text-muted-foreground text-xs">
|
|
{row.original.chain || row.original.table || '—'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'action',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Action" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="text-xs">{row.original.action || '—'}</span>
|
|
),
|
|
},
|
|
{
|
|
id: 'ports',
|
|
accessorFn: (r) =>
|
|
[r.protocol, r.dport].filter(Boolean).join('/') || '—',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Proto/Port" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="font-mono text-xs tabular-nums">
|
|
{[row.original.protocol, row.original.dport]
|
|
.filter(Boolean)
|
|
.join('/') || '—'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'saddr',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Src" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="font-mono text-xs">
|
|
{row.original.saddr || '—'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'raw',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Raw" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span
|
|
className="text-muted-foreground block max-w-[280px] truncate font-mono text-[11px]"
|
|
title={row.original.raw}
|
|
>
|
|
{row.original.raw}
|
|
</span>
|
|
),
|
|
},
|
|
],
|
|
[],
|
|
)
|
|
|
|
const listenerCols = useMemo<ColumnDef<HostListenerDto>[]>(
|
|
() => [
|
|
{
|
|
accessorKey: 'protocol',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Proto" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="font-mono text-xs uppercase">
|
|
{row.original.protocol}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'port',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Port" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="font-mono text-xs tabular-nums">
|
|
{row.original.port}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'address',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Address" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="font-mono text-xs">{row.original.address}</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'process',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Process" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="text-muted-foreground text-xs">
|
|
{row.original.process || '—'}
|
|
</span>
|
|
),
|
|
},
|
|
],
|
|
[],
|
|
)
|
|
|
|
const rulesTable = useReactTable({
|
|
data: rules,
|
|
columns: ruleCols,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getRowId: (r, i) => `${r.backend}-${r.chain}-${i}-${r.raw.slice(0, 40)}`,
|
|
})
|
|
const listenersTable = useReactTable({
|
|
data: listeners,
|
|
columns: listenerCols,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getRowId: (r, i) => `${r.protocol}-${r.address}-${r.port}-${i}`,
|
|
})
|
|
|
|
return (
|
|
<Frame dense spacing="sm">
|
|
<FrameHeader>
|
|
<FrameTitle>Host firewall</FrameTitle>
|
|
<FrameDescription>
|
|
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
|
|
{q.data?.collected_at
|
|
? ` Обновлено: ${formatDateTime(q.data.collected_at)}`
|
|
: ' Пока нет снимка — дождитесь sync агента.'}
|
|
</FrameDescription>
|
|
</FrameHeader>
|
|
<FramePanel className="flex flex-col gap-3 p-4">
|
|
<CountedLineTabs
|
|
value={tab}
|
|
onValueChange={setTab}
|
|
tabs={[
|
|
{ id: 'rules', label: 'Rules', count: rules.length },
|
|
{ id: 'listeners', label: 'Listeners', count: listeners.length },
|
|
]}
|
|
>
|
|
<TabsContent value="rules" className="mt-3 flex flex-col gap-3">
|
|
<div className="flex flex-wrap gap-2">
|
|
<Select
|
|
value={ownership}
|
|
onValueChange={(v) => {
|
|
if (v) setOwnership(v as typeof ownership)
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-[140px]">
|
|
<SelectValue placeholder="Owner" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All owners</SelectItem>
|
|
<SelectItem value="evofw">EvoFW</SelectItem>
|
|
<SelectItem value="foreign">Foreign</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
value={backend}
|
|
onValueChange={(v) => {
|
|
if (v) setBackend(v)
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-[140px]">
|
|
<SelectValue placeholder="Backend" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All backends</SelectItem>
|
|
{backends.map((b) => (
|
|
<SelectItem key={b} value={b}>
|
|
{b}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{q.isLoading ? (
|
|
<div className="flex flex-col gap-2">
|
|
<Skeleton className="h-8 w-full" />
|
|
<Skeleton className="h-8 w-full" />
|
|
</div>
|
|
) : rules.length === 0 ? (
|
|
<EmptyState
|
|
icon={ShieldIcon}
|
|
title="Нет правил в снимке"
|
|
description="После sync Linux-агент пришлёт host_firewall."
|
|
centered={false}
|
|
className="py-6"
|
|
/>
|
|
) : (
|
|
<DataGrid
|
|
table={rulesTable}
|
|
recordCount={rules.length}
|
|
tableLayout={{ dense: true }}
|
|
>
|
|
<DataGridTable />
|
|
</DataGrid>
|
|
)}
|
|
</TabsContent>
|
|
<TabsContent value="listeners" className="mt-3">
|
|
{q.isLoading ? (
|
|
<Skeleton className="h-8 w-full" />
|
|
) : listeners.length === 0 ? (
|
|
<EmptyState
|
|
icon={ShieldIcon}
|
|
title="Нет listeners"
|
|
description="ss -lntu не вернул сокеты или снимок пуст."
|
|
centered={false}
|
|
className="py-6"
|
|
/>
|
|
) : (
|
|
<DataGrid
|
|
table={listenersTable}
|
|
recordCount={listeners.length}
|
|
tableLayout={{ dense: true }}
|
|
>
|
|
<DataGridTable />
|
|
</DataGrid>
|
|
)}
|
|
</TabsContent>
|
|
</CountedLineTabs>
|
|
</FramePanel>
|
|
</Frame>
|
|
)
|
|
}
|