Files
DenozordecandCursor 5f774ce26e
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 3m1s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m31s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
feat(gre): enhance GRE tunnel status handling and IPsec configuration
- Introduced a new `greStatusMeta` function to streamline status retrieval and default handling.
- Updated GRE tunnel command generation to improve IPsec configuration handling, including optional fields for encryption and authentication algorithms.
- Enhanced the GRE tunnels data grid to display IPsec settings more clearly, including handling cases where certain parameters may be undefined.
- Refactored status display logic in the GRE page to utilize the new status meta function for consistency.

Co-authored-by: Cursor <[email protected]>
2026-09-11 15:39:32 +07:00

372 lines
13 KiB
TypeScript
Raw Permalink 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 { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type {
GrePool,
GreTunnel,
GreStatus,
IpsecEncAlg,
IpsecAuthAlg,
IpsecDhGroup,
IkeVersion,
Server,
} from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
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 {
CodeXmlIcon,
LockIcon,
LockOpenIcon,
MoreHorizontalIcon,
NetworkIcon,
PencilIcon,
PowerIcon,
Trash2Icon,
} from "lucide-react"
const ENC_LABELS: Record<IpsecEncAlg, string> = {
"aes-128": "AES-128",
"aes-192": "AES-192",
"aes-256": "AES-256",
}
const AUTH_LABELS: Record<IpsecAuthAlg, string> = {
sha1: "SHA-1",
sha256: "SHA-256",
sha512: "SHA-512",
}
const DH_LABELS: Record<IpsecDhGroup, string> = {
modp1024: "DH-2 (1024)",
modp2048: "DH-14 (2048)",
modp4096: "DH-16 (4096)",
ecp256: "ECP-256",
ecp384: "ECP-384",
ecp521: "ECP-521",
}
const IKE_LABELS: Record<IkeVersion, string> = { ikev1: "IKEv1", ikev2: "IKEv2" }
const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
up: { label: "Up", dot: "bg-emerald-500" },
degraded: { label: "Degraded", dot: "bg-amber-500" },
down: { label: "Down", dot: "bg-red-500" },
}
function TunnelStatus({ status }: { status: GreStatus }) {
const s = STATUS_MAP[status] ?? STATUS_MAP.degraded
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={cn("size-1.5 rounded-full", s.dot)} />
{s.label}
</span>
)
}
function IpsecBadge({ secured }: { secured: boolean }) {
return secured ? (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
<LockIcon className="size-3" /> IPsec
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-muted text-muted-foreground border-border">
<LockOpenIcon className="size-3" /> Открытый
</span>
)
}
interface GreTunnelsDataGridProps {
tunnels: GreTunnel[]
servers: Server[]
pools: GrePool[]
onCodePreview: (tunnel: GreTunnel) => void
onEdit?: (tunnel: GreTunnel) => void
onToggle?: (tunnel: GreTunnel) => void
onDelete?: (tunnel: GreTunnel) => void
mutationsLocked?: boolean
}
function GreTunnelsDataGrid({
tunnels,
servers,
pools,
onCodePreview,
onEdit,
onToggle,
onDelete,
mutationsLocked = false,
}: GreTunnelsDataGridProps) {
const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers])
const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools])
const columns = useMemo<ColumnDef<GreTunnel>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
),
cell: ({ row }) => {
const t = row.original
const srv = serverMap.get(t.serverId)
return (
<div className="min-w-0">
<p className="font-medium font-mono text-[13px]">{t.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
{srv && <Flag code={srv.country} />}
{srv?.name ?? t.serverId}
</p>
</div>
)
},
meta: {
headerTitle: "Интерфейс / Сервер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "endpoints",
header: () => <span className="text-xs font-medium text-muted-foreground">Эндпоинты</span>,
enableSorting: false,
cell: ({ row }) => {
const t = row.original
return (
<div>
<p className="font-mono text-xs">
{t.localAddress === "0.0.0.0" ? (
<span className="text-muted-foreground">авто</span>
) : (
t.localAddress
)}
</p>
<p className="font-mono text-xs text-muted-foreground"> {t.remoteAddress}</p>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "innerIp",
header: () => <span className="text-xs font-medium text-muted-foreground">Внутренний IP</span>,
enableSorting: false,
cell: ({ row }) => {
const t = row.original
return (
<div>
<p className="font-mono text-xs">{t.localInnerIp}</p>
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "pool",
accessorKey: "poolId",
header: ({ column }) => <DataGridSortHeader column={column} title="Пул" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground font-mono">
{poolMap.get(row.original.poolId)?.name ?? "—"}
</span>
),
meta: { headerTitle: "Пул", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "ipsec",
header: () => <span className="text-xs font-medium text-muted-foreground">IPsec</span>,
enableSorting: false,
cell: ({ row }) => <IpsecBadge secured={!!row.original.ipsec} />,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "encryption",
header: () => <span className="text-xs font-medium text-muted-foreground">Шифрование</span>,
enableSorting: false,
cell: ({ row }) => {
const t = row.original
if (!t.ipsec) return <span className="text-xs text-muted-foreground"></span>
const enc = t.ipsec.encAlg ? ENC_LABELS[t.ipsec.encAlg] : undefined
const auth = t.ipsec.authAlg ? AUTH_LABELS[t.ipsec.authAlg] : undefined
const dh = t.ipsec.dhGroup ? DH_LABELS[t.ipsec.dhGroup] : undefined
const ike = t.ipsec.ikeVersion ? IKE_LABELS[t.ipsec.ikeVersion] : undefined
if (!enc && !auth && !dh && !ike) {
return <span className="text-xs text-muted-foreground">PSK · auto</span>
}
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-mono">
{[enc, auth].filter(Boolean).join(" / ") || "PSK"}
</span>
<span className="text-xs text-muted-foreground font-mono">
{[dh?.split(" ")[0], ike].filter(Boolean).join(" · ")}
{t.ipsec.pfs ? " · PFS" : ""}
</span>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "mtu",
accessorKey: "mtu",
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-center block">{row.original.mtu}</span>
),
meta: {
headerTitle: "MTU",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "keepalive",
header: () => <span className="text-xs font-medium text-muted-foreground">Keepalive</span>,
enableSorting: false,
cell: ({ row }) => {
const t = row.original
return (
<span className="font-mono text-xs text-muted-foreground">
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
</span>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => <TunnelStatus status={row.original.status} />,
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
cell: ({ row }) => {
const t = row.original
return (
<div className="flex items-center gap-1 justify-end">
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-7 opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
)}
title="Предпросмотр кода RouterOS"
onClick={(e) => {
e.stopPropagation()
onCodePreview(t)
}}
>
<CodeXmlIcon className="size-3.5" />
</Button>
<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={`Действия: ${t.name}`}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onCodePreview(t)}>
<CodeXmlIcon className="size-4" /> Просмотр кода
</DropdownMenuItem>
<DropdownMenuItem disabled={mutationsLocked || !onEdit} onClick={() => onEdit?.(t)}>
<PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={mutationsLocked || !onToggle} onClick={() => onToggle?.(t)}>
<PowerIcon className="size-4" />
{t.enabled ? "Выключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={mutationsLocked || !onDelete}
onClick={() => onDelete?.(t)}
>
<Trash2Icon className="size-4" /> Удалить туннель
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
size: 88,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, poolMap, serverMap],
)
const table = useReactTable({
data: tunnels,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row, index) => `${row.id}:${row.serverId}:${row.name}:${index}`,
})
if (tunnels.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет GRE-туннелей"
description="Измените фильтр или добавьте туннель"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={tunnels.length}
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
/>
)
}
export { GreTunnelsDataGrid, type GreTunnelsDataGridProps }