Files
Denozordec 6123660346
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
feat(terminal, wireguard): enhance UI components and functionality
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.
2026-09-06 01:23:40 +07:00

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 }