Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo } from "react"
|
||
import {
|
||
type ColumnDef,
|
||
getCoreRowModel,
|
||
getSortedRowModel,
|
||
useReactTable,
|
||
} 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,
|
||
} from "@/components/data-grids/shared/data-grid-layout"
|
||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||
import { EmptyState } from "@/components/empty-state"
|
||
import {
|
||
CopyIcon,
|
||
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[]
|
||
onToggle: (id: string) => void
|
||
onEdit: (rule: FirewallRule) => void
|
||
}
|
||
|
||
function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) {
|
||
const indexedRules = useMemo(
|
||
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||
[rules],
|
||
)
|
||
|
||
const columns = useMemo<ColumnDef<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: DATA_GRID_CELL_PAD_FIRST,
|
||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||
},
|
||
},
|
||
{
|
||
id: "chain",
|
||
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",
|
||
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",
|
||
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",
|
||
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",
|
||
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",
|
||
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",
|
||
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",
|
||
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",
|
||
accessorKey: "enabled",
|
||
header: () => <span className="text-xs font-medium text-muted-foreground">Вкл</span>,
|
||
enableSorting: false,
|
||
cell: ({ row }) => (
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
<FormToggle 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-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
|
||
"data-popup-open:opacity-100",
|
||
)}
|
||
aria-label={`Действия: ${r.chain} ${r.action}`}
|
||
>
|
||
<MoreHorizontalIcon className="size-4" />
|
||
</Button>
|
||
}
|
||
/>
|
||
<DropdownMenuContent side="bottom" align="end">
|
||
<DropdownMenuItem onClick={() => onEdit(r)}>
|
||
<PencilIcon className="size-4" />
|
||
Редактировать
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem>
|
||
<CopyIcon className="size-4" />
|
||
Дублировать
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem onClick={() => onToggle(r.id)}>
|
||
<PowerIcon className="size-4" />
|
||
{r.enabled ? "Отключить" : "Включить"}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem variant="destructive">
|
||
<Trash2Icon className="size-4" />
|
||
Удалить правило
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
)
|
||
},
|
||
size: 56,
|
||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||
},
|
||
],
|
||
[onEdit, onToggle],
|
||
)
|
||
|
||
const table = useReactTable({
|
||
data: indexedRules,
|
||
columns,
|
||
getCoreRowModel: getCoreRowModel(),
|
||
getSortedRowModel: getSortedRowModel(),
|
||
getRowId: (row) => row.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}
|
||
tableClassNames={{
|
||
headerRow: "border-b border-border",
|
||
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export { FirewallRulesDataGrid, type FirewallRulesDataGridProps, ActionBadge, ChainBadge }
|