Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 2m45s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 10s
Updated the TerminalPage to include a new ServerTileRail for server selection and added QuickCmds for quick command execution. Enhanced the WireGuardPage with new tabs for interface and peer management, improved server selection handling, and added support for displaying peer details. Refactored data grids to support compact server views and improved empty state handling. Introduced new hooks for managing server states and improved user experience across components.
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, type ReactNode } from "react"
|
|
import {
|
|
type ColumnDef,
|
|
getCoreRowModel,
|
|
getExpandedRowModel,
|
|
getSortedRowModel,
|
|
useReactTable,
|
|
} 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
|
|
emptyAction?: ReactNode
|
|
onExport: (iface: WgIfaceWithServer) => void
|
|
onAddPeer?: (iface: WgIfaceWithServer) => void
|
|
onToggle?: (iface: WgIfaceWithServer) => void
|
|
onDelete?: (iface: WgIfaceWithServer) => void
|
|
onDeletePeer?: (iface: WgIfaceWithServer, peerId: string) => void
|
|
onExportPeer?: (iface: WgIfaceWithServer, peerId: string) => void
|
|
}
|
|
|
|
function WireguardDataGrid({
|
|
interfaces,
|
|
compactServer = false,
|
|
emptyAction,
|
|
onExport,
|
|
onAddPeer,
|
|
onToggle,
|
|
onDelete,
|
|
onDeletePeer,
|
|
onExportPeer,
|
|
}: WireguardDataGridProps) {
|
|
const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>(
|
|
() => [
|
|
{
|
|
id: "name",
|
|
accessorKey: "name",
|
|
header: ({ column }) => (
|
|
<DataGridSortHeader
|
|
column={column}
|
|
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
|
|
className="ml-1"
|
|
/>
|
|
),
|
|
cell: ({ row }) => {
|
|
const iface = row.original
|
|
const expanded = row.getIsExpanded()
|
|
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
|
return (
|
|
<div className="flex items-start gap-2 min-w-0">
|
|
{expanded ? (
|
|
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
|
)}
|
|
<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="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">
|
|
{onlinePeers}/{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}
|
|
onDeletePeer={onDeletePeer ? (peerId) => onDeletePeer(row, peerId) : undefined}
|
|
onExportPeer={onExportPeer ? (peerId) => onExportPeer(row, peerId) : undefined}
|
|
/>
|
|
),
|
|
},
|
|
},
|
|
{
|
|
id: "listenPort",
|
|
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",
|
|
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",
|
|
header: () => (
|
|
<span className="text-xs font-medium text-muted-foreground">Пиры</span>
|
|
),
|
|
enableSorting: false,
|
|
cell: ({ row }) => {
|
|
const iface = row.original
|
|
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
|
return (
|
|
<span className="font-mono text-sm text-center block">
|
|
<span className="text-success">{onlinePeers}</span>
|
|
<span className="text-muted-foreground">/{iface.peers.length}</span>
|
|
</span>
|
|
)
|
|
},
|
|
meta: {
|
|
headerTitle: "Пиры",
|
|
headerClassName: DATA_GRID_CELL_PAD,
|
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
|
},
|
|
},
|
|
{
|
|
id: "status",
|
|
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: ({ row }) => {
|
|
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-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
|
"opacity-0 transition-[opacity,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 onClick={() => onExport(iface)}>
|
|
<CodeXmlIcon className="size-4" />
|
|
Экспорт
|
|
</DropdownMenuItem>
|
|
{onAddPeer && (
|
|
<DropdownMenuItem onClick={() => onAddPeer(iface)}>
|
|
<PlusIcon className="size-4" />
|
|
Добавить пира
|
|
</DropdownMenuItem>
|
|
)}
|
|
{onToggle && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => onToggle(iface)}>
|
|
<PowerIcon className="size-4" />
|
|
{iface.enabled ? "Отключить" : "Включить"}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
{onDelete && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem variant="destructive" onClick={() => onDelete(iface)}>
|
|
<Trash2Icon className="size-4" />
|
|
Удалить
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)
|
|
},
|
|
enableSorting: false,
|
|
size: 56,
|
|
meta: {
|
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
|
},
|
|
},
|
|
],
|
|
[compactServer, onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
|
|
)
|
|
|
|
const table = useReactTable({
|
|
data: interfaces,
|
|
columns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getExpandedRowModel: getExpandedRowModel(),
|
|
getRowId: (row) => row.id,
|
|
getRowCanExpand: () => true,
|
|
})
|
|
|
|
if (interfaces.length === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon={<ShieldCheckIcon className="size-4" />}
|
|
title="Нет WireGuard интерфейсов"
|
|
description="Добавьте первый интерфейс или сбросьте фильтры"
|
|
action={emptyAction}
|
|
className="border-0 py-16"
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<DataGridShell
|
|
table={table}
|
|
recordCount={interfaces.length}
|
|
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
|
tableClassNames={{
|
|
headerRow: "border-b border-border",
|
|
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export { WireguardDataGrid, type WireguardDataGridProps }
|