Files
MikrotikManager/components/data-grids/firewall-rules-data-grid.tsx
T

408 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { useEffect, useMemo } from "react"
import {
type ColumnDef,
useTable,
} from "@tanstack/react-table"
import type { FirewallRule } from "@/lib/data"
import { FormToggle } from "@/components/form-kit"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
DATA_GRID_CONTAINER_CLASS,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { DataGridContainer, DataGridScrollArea, DataGridTable, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid"
import type { DragEndEvent } from "@dnd-kit/core"
import { EmptyState } from "@/components/empty-state"
import {
CopyIcon,
ChevronUpIcon,
ChevronDownIcon,
MoreHorizontalIcon,
PencilIcon,
PowerIcon,
ShieldOffIcon,
Trash2Icon,
} from "lucide-react"
const ACTION_STYLES: Record<string, string> = {
accept: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
drop: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
reject: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
masquerade: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
"mark-routing": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
"mark-conn": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
"fasttrack-connection": "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
"dst-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
"src-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
"add-src-to-address-list": "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
}
const CHAIN_STYLES: Record<string, string> = {
forward: "bg-foreground/5 text-foreground/70",
input: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
output: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
srcnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
dstnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
prerouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
postrouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
"ip6-input": "bg-violet-500/10 text-violet-600 dark:text-violet-400",
"ip6-forward": "bg-foreground/5 text-foreground/70",
"ip6-output": "bg-sky-500/10 text-sky-600 dark:text-sky-400",
}
function fmtHits(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}М`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}к`
return String(n)
}
function ActionBadge({ action }: { action: string }) {
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
return (
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
{action}
</span>
)
}
function ChainBadge({ chain }: { chain: string }) {
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
return (
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
{chain}
</span>
)
}
interface FirewallRulesDataGridProps {
rules: FirewallRule[]
mutationsDisabled?: boolean
onToggle: (id: string) => void
onEdit: (rule: FirewallRule) => void
onDelete?: (rule: FirewallRule) => void
onReorder?: (activeId: string, overId: string) => void
showServer?: boolean
}
function FirewallRulesDataGrid({
rules,
mutationsDisabled = false,
onToggle,
onEdit,
onDelete,
onReorder,
showServer = false,
}: FirewallRulesDataGridProps) {
const indexedRules = useMemo(
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
[rules],
)
const neighbours = useMemo(() => {
const groups = new Map<string, FirewallRule[]>()
for (const rule of rules) {
const key = [rule.serverId, rule.family, rule.table].join(":")
const group = groups.get(key) ?? []
group.push(rule); groups.set(key, group)
}
const result = new Map<string, { up?: string; down?: string }>()
for (const group of groups.values()) group.forEach((rule, index) => result.set(rule.id, { up: group[index - 1]?.id, down: group[index + 1]?.id }))
return result
}, [rules])
const reorderable = Boolean(onReorder)
const indexPad = reorderable ? DATA_GRID_CELL_PAD : DATA_GRID_CELL_PAD_FIRST
const columns = useMemo<ColumnDef<DataGridFeatures, FirewallRule & { _index: number }>[]>(
() => [
...(reorderable
? [{
id: "drag",
header: () => <span className="sr-only">Порядок</span>,
enableSorting: false,
cell: ({ row }) => {
const targets = neighbours.get(row.original.id)
return <div className="flex items-center gap-1">
<DataGridTableDndRowHandle className="size-11 shrink-0" />
<Button type="button" variant="ghost" size="icon" className="size-11 shrink-0" aria-label={`Поднять правило ${row.original.rosId || row.original.id}`} disabled={!targets?.up || mutationsDisabled} onClick={() => targets?.up && onReorder?.(row.original.id, targets.up)}><ChevronUpIcon /></Button>
<Button type="button" variant="ghost" size="icon" className="size-11 shrink-0" aria-label={`Опустить правило ${row.original.rosId || row.original.id}`} disabled={!targets?.down || mutationsDisabled} onClick={() => targets?.down && onReorder?.(row.original.id, targets.down)}><ChevronDownIcon /></Button>
</div>
},
size: 160,
meta: {
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
} satisfies ColumnDef<DataGridFeatures, FirewallRule & { _index: number }>]
: []),
{
id: "index",
accessorKey: "_index",
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
enableSorting: false,
cell: ({ row }) => (
<span
className="font-mono text-xs text-muted-foreground"
data-rule-disabled={!row.original.enabled ? true : undefined}
>
{row.original._index}
</span>
),
size: 48,
meta: {
headerClassName: indexPad,
cellClassName: indexPad,
},
},
...(showServer
? [{
id: "server",
accessorFn: (row: FirewallRule & { _index: number }) => row.serverName ?? row.serverId ?? "",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground truncate">
{row.original.serverName || row.original.serverId || "—"}
</span>
),
meta: { headerTitle: "Сервер", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
} satisfies ColumnDef<DataGridFeatures, FirewallRule & { _index: number }>]
: []),
{
id: "chain",
size: 112,
accessorKey: "chain",
header: ({ column }) => <DataGridSortHeader column={column} title="Цепочка" />,
cell: ({ row }) => <ChainBadge chain={row.original.chain} />,
meta: { headerTitle: "Цепочка", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "action",
size: 176,
accessorKey: "action",
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
cell: ({ row }) => <ActionBadge action={row.original.action} />,
meta: { headerTitle: "Действие", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "src",
size: 176,
accessorKey: "src",
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
{row.original.src || "any"}
</span>
),
meta: { headerTitle: "Источник", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "dst",
size: 176,
accessorKey: "dst",
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
{row.original.dst || "any"}
</span>
),
meta: { headerTitle: "Назначение", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "proto",
size: 96,
accessorKey: "proto",
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
cell: ({ row }) => <span className="text-xs font-mono">{row.original.proto}</span>,
meta: { headerTitle: "Протокол", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "port",
size: 112,
accessorKey: "port",
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.port || "—"}</span>
),
meta: { headerTitle: "Порт", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "iface",
size: 160,
accessorKey: "iface",
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.iface || "—"}</span>
),
meta: { headerTitle: "Интерфейс", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "hits",
size: 112,
accessorKey: "hits",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пакетов" className="ml-auto" />
),
cell: ({ row }) => {
const hits = row.original.hits
return (
<span
className={cn(
"text-xs font-mono tabular-nums text-right block",
hits > 1_000_000
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: hits > 10_000
? "text-foreground"
: "text-muted-foreground",
)}
>
{fmtHits(hits)}
</span>
)
},
meta: {
headerTitle: "Пакетов",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
{
id: "enabled",
size: 64,
accessorKey: "enabled",
header: () => <span className="text-xs font-medium text-muted-foreground">Вкл</span>,
enableSorting: false,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<FormToggle ariaLabel={`Включено правило ${row.original.rosId || row.original.id}`} disabled={mutationsDisabled} checked={row.original.enabled} onChange={() => onToggle(row.original.id)} />
</div>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
cell: ({ row }) => {
const r = row.original
return (
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"size-11 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"transition-colors",
"data-popup-open:opacity-100",
)}
aria-label={`Действия: ${r.chain} ${r.action}`}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onEdit(r)}>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<CopyIcon className="size-4" />
Дублировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onToggle(r.id)}>
<PowerIcon className="size-4" />
{r.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={mutationsDisabled} variant="destructive" onClick={() => onDelete?.(r)}>
<Trash2Icon className="size-4" />
Удалить правило
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
size: 64,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[onEdit, onToggle, onDelete, onReorder, neighbours, showServer, reorderable, indexPad, mutationsDisabled],
)
const table = useTable({
features: dataGridFeatures,
manualPagination: false,
autoResetPageIndex: false,
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["index", "chain"], end: [] } },
data: indexedRules,
columns,
manualSorting: reorderable,
enableSorting: !reorderable,
getRowId: (row) => row.id,
})
const { pageIndex, pageSize } = table.state.pagination
useEffect(() => {
const lastPage = Math.max(0, Math.ceil(rules.length / pageSize) - 1)
if (pageIndex > lastPage) table.setPageIndex(lastPage)
}, [rules.length, pageIndex, pageSize, table])
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id || !onReorder) return
onReorder(String(active.id), String(over.id))
}
if (rules.length === 0) {
return (
<EmptyState
icon={<ShieldOffIcon className="size-4" />}
title="Правила не найдены"
description="Попробуйте изменить фильтр или добавьте новое правило"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={rules.length}
pagination={rules.length > 50 || pageSize !== 50}
tableLayout={{ dense: true, columnsPinnable: true }}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
}}
>
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
<DataGridScrollArea>
{reorderable ? <DataGridTableDndRows dataIds={table.getRowModel().rows.map(row => row.id)} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
</DataGridScrollArea>
</DataGridContainer>
</DataGridShell>
)
}
export { FirewallRulesDataGrid, type FirewallRulesDataGridProps, ActionBadge, ChainBadge }