Files
MikrotikManager/components/data-grids/wireguard-data-grid.tsx

337 lines
12 KiB
TypeScript

"use client"
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { createContext, useContext, useEffect, useMemo, type ReactNode } from "react"
import {
type ColumnDef,
useTable,
} from "@tanstack/react-table"
import type { WireGuardInterface } from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/reui/badge"
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 { WireGuardPeersDetail } from "@/components/data-grids/wireguard-peers-detail"
import { EmptyState } from "@/components/empty-state"
import {
ChevronDownIcon,
ChevronRightIcon,
CodeXmlIcon,
MoreHorizontalIcon,
PlusIcon,
PowerIcon,
ShieldCheckIcon,
Trash2Icon,
} from "lucide-react"
export interface WgIfaceWithServer extends WireGuardInterface {
serverId: string
serverName: string
serverCountry: string
}
interface WireguardDataGridProps {
interfaces: WgIfaceWithServer[]
compactServer?: boolean
canWrite?: boolean
emptyAction?: ReactNode
onExport: (iface: WgIfaceWithServer) => void
onAddPeer?: (iface: WgIfaceWithServer) => void
toggleBusy?: boolean
onToggle?: (iface: WgIfaceWithServer) => void
onDelete?: (iface: WgIfaceWithServer) => void
onDeletePeer?: (iface: WgIfaceWithServer, peerId: string) => void
onExportPeer?: (iface: WgIfaceWithServer, peerId: string) => void
}
type WireguardActions = Pick<WireguardDataGridProps, "onExport" | "onAddPeer" | "onToggle" | "toggleBusy" | "onDelete" | "canWrite">
const WireguardActionsContext = createContext<WireguardActions | null>(null)
// Preserve menu state while callbacks and the current row data update.
function WireguardActionsCell({ row }: { row: { original: WgIfaceWithServer } }) {
const actions = useContext(WireguardActionsContext)
if (!actions) throw new Error("WireGuard actions context is missing")
const { onExport, onAddPeer, onToggle, toggleBusy, onDelete, canWrite = true } = actions
const iface = row.original
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
onKeyDown={(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-[background-color,color,border-color]",
"group-hover/row:opacity-100 focus-visible:opacity-100",
"data-popup-open:opacity-100 data-popup-open:bg-muted",
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Действия: ${iface.name}`}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem disabled={!canWrite} onClick={() => onExport(iface)}>
<CodeXmlIcon className="size-4" />
Экспорт
</DropdownMenuItem>
{onAddPeer && (
<DropdownMenuItem disabled={!canWrite} onClick={() => onAddPeer(iface)}>
<PlusIcon className="size-4" />
Добавить пира
</DropdownMenuItem>
)}
{onToggle && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!canWrite || toggleBusy} onClick={() => onToggle(iface)}>
<PowerIcon className="size-4" />
{iface.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
</>
)}
{onDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!canWrite} variant="destructive" onClick={() => onDelete(iface)}>
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
function WireguardDataGrid({
interfaces,
compactServer = false,
canWrite = true,
emptyAction,
onExport,
onAddPeer,
onToggle,
toggleBusy = false,
onDelete,
onDeletePeer,
onExportPeer,
}: WireguardDataGridProps) {
const columns = useMemo<ColumnDef<DataGridFeatures, WgIfaceWithServer>[]>(
() => [
{
id: "name",
size: 176,
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader
column={column}
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
className="ml-1"
/>
),
cell: ({ row }) => {
const iface = row.original
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
<Button type="button" variant="ghost" size="icon" className="size-11 shrink-0" aria-label={"Пиры: " + iface.name} aria-expanded={expanded} onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
{expanded ? <ChevronDownIcon aria-hidden="true" className="size-3.5" /> : <ChevronRightIcon aria-hidden="true" className="size-3.5" />}
</Button>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
"size-2 rounded-full shrink-0",
iface.status === "up" ? "bg-success animate-pulse" : "bg-destructive",
)}
/>
<span className="min-w-0 break-all font-mono font-semibold text-sm">{iface.name}</span>
</div>
{!compactServer ? (
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry || "UN"} size={12} />
{iface.serverName}
</div>
) : null}
<p className="sr-only">
{iface.peers.length} пиров
</p>
</div>
</div>
)
},
meta: {
headerTitle: compactServer ? "Интерфейс" : "Интерфейс / Сервер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: WgIfaceWithServer) => (
<WireGuardPeersDetail
peers={row.peers}
interfaceName={row.name}
onDeletePeer={onDeletePeer ? (peerId) => onDeletePeer(row, peerId) : undefined}
onExportPeer={onExportPeer ? (peerId) => onExportPeer(row, peerId) : undefined}
/>
),
},
},
{
id: "listenPort",
size: 100,
accessorKey: "listenPort",
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
cell: ({ row }) => (
<span className="font-mono text-sm">{row.original.listenPort}</span>
),
meta: {
headerTitle: "Порт",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "mtu",
size: 80,
accessorKey: "mtu",
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
cell: ({ row }) => <span className="font-mono text-sm">{row.original.mtu}</span>,
meta: {
headerTitle: "MTU",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "peers",
size: 80,
header: () => (
<span className="text-xs font-medium text-muted-foreground">Пиры</span>
),
enableSorting: false,
cell: ({ row }) => {
const iface = row.original
return (
<span className="font-mono text-sm text-center block">
{iface.peers.length}
</span>
)
},
meta: {
headerTitle: "Пиры",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "status",
size: 104,
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<Badge
variant={row.original.status === "up" ? "success-light" : "destructive-light"}
size="sm"
className="font-mono"
>
{row.original.status === "up" ? "UP" : "DOWN"}
</Badge>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
cell: WireguardActionsCell,
enableSorting: false,
size: 72,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[compactServer, onDeletePeer, onExportPeer],
)
const table = useTable({
features: dataGridFeatures,
manualPagination: false,
autoResetPageIndex: false,
autoResetExpanded: false,
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
data: interfaces,
columns,
getRowId: (row) => JSON.stringify([row.serverId, row.id]),
getRowCanExpand: () => true,
})
const { pageIndex, pageSize } = table.state.pagination
useEffect(() => {
const lastPage = Math.max(0, Math.ceil(interfaces.length / pageSize) - 1)
if (pageIndex > lastPage) table.setPageIndex(lastPage)
}, [interfaces.length, pageIndex, pageSize, table])
if (interfaces.length === 0) {
return (
<EmptyState
icon={<ShieldCheckIcon className="size-4" />}
title="Нет WireGuard интерфейсов"
description="Добавьте первый интерфейс или сбросьте фильтры"
action={emptyAction}
className="border-0 py-16"
/>
)
}
return (
<WireguardActionsContext.Provider value={{ onExport, onAddPeer, onToggle, toggleBusy, onDelete, canWrite }}>
<DataGridShell
table={table}
recordCount={interfaces.length}
pagination={interfaces.length > 50 || pageSize !== 50}
tableLayout={{ dense: true, columnsPinnable: true }}
onRowClick={(row) => table.getRow(JSON.stringify([row.serverId, row.id])).toggleExpanded()}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
}}
>
<DataGridContainer border={false} className="w-full rounded-none border-0">
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
</DataGridContainer>
</DataGridShell>
</WireguardActionsContext.Provider>
)
}
export { WireguardDataGrid, type WireguardDataGridProps }