feat(terminal, wireguard): enhance UI components and functionality
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.
This commit is contained in:
Denozordec
2026-09-06 01:23:40 +07:00
parent b680f882cc
commit 6123660346
9 changed files with 1123 additions and 260 deletions
+20 -9
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo } from "react"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
@@ -48,6 +48,8 @@ export interface WgIfaceWithServer extends WireGuardInterface {
interface WireguardDataGridProps {
interfaces: WgIfaceWithServer[]
compactServer?: boolean
emptyAction?: ReactNode
onExport: (iface: WgIfaceWithServer) => void
onAddPeer?: (iface: WgIfaceWithServer) => void
onToggle?: (iface: WgIfaceWithServer) => void
@@ -58,6 +60,8 @@ interface WireguardDataGridProps {
function WireguardDataGrid({
interfaces,
compactServer = false,
emptyAction,
onExport,
onAddPeer,
onToggle,
@@ -71,7 +75,11 @@ function WireguardDataGrid({
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
<DataGridSortHeader
column={column}
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
className="ml-1"
/>
),
cell: ({ row }) => {
const iface = row.original
@@ -94,10 +102,12 @@ function WireguardDataGrid({
/>
<span className="font-mono font-semibold text-sm">{iface.name}</span>
</div>
<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>
{!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>
@@ -106,7 +116,7 @@ function WireguardDataGrid({
)
},
meta: {
headerTitle: "Интерфейс / Сервер",
headerTitle: compactServer ? "Интерфейс" : "Интерфейс / Сервер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: WgIfaceWithServer) => (
@@ -256,7 +266,7 @@ function WireguardDataGrid({
},
},
],
[onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
[compactServer, onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
)
const table = useReactTable({
@@ -274,7 +284,8 @@ function WireguardDataGrid({
<EmptyState
icon={<ShieldCheckIcon className="size-4" />}
title="Нет WireGuard интерфейсов"
description="Добавьте первый интерфейс или проверьте поиск"
description="Добавьте первый интерфейс или сбросьте фильтры"
action={emptyAction}
className="border-0 py-16"
/>
)
@@ -74,18 +74,18 @@ function WireGuardPeersDetail({
<span
className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
peer.latestHandshake ? "text-success" : "text-muted-foreground",
)}
>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />
<ArrowDownIcon className="size-3 text-success" />
{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />
<ArrowUpIcon className="size-3 text-info" />
{fmtBytes(peer.transferTx)}
</span>
</div>
@@ -0,0 +1,268 @@
"use client"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { WireGuardPeer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
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 { fmtBytes, truncKey } from "@/components/data-grids/wireguard-peers-detail"
import {
ArrowDownIcon,
ArrowUpIcon,
CodeXmlIcon,
KeyRoundIcon,
Trash2Icon,
UsersIcon,
} from "lucide-react"
export interface WgPeerRow extends WireGuardPeer {
id: string
ifaceId: string
ifaceName: string
serverId: string
serverName: string
serverCountry: string
}
interface WireguardPeersGridProps {
peers: WgPeerRow[]
compactServer?: boolean
emptyAction?: ReactNode
onDeletePeer?: (row: WgPeerRow) => void
onExportPeer?: (row: WgPeerRow) => void
}
function WireguardPeersGrid({
peers,
compactServer = false,
emptyAction,
onDeletePeer,
onExportPeer,
}: WireguardPeersGridProps) {
const columns = useMemo<ColumnDef<WgPeerRow>[]>(() => {
const cols: ColumnDef<WgPeerRow>[] = [
{
id: "peer",
accessorKey: "publicKey",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пир" className="ml-1" />
),
cell: ({ row }) => {
const peer = row.original
return (
<div className="flex min-w-0 items-center gap-1.5">
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-mono text-xs" title={peer.publicKey}>
{peer.name || truncKey(peer.publicKey)}
</span>
</div>
)
},
meta: {
headerTitle: "Пир",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "iface",
accessorKey: "ifaceName",
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
cell: ({ row }) => (
<span className="font-mono text-sm">{row.original.ifaceName}</span>
),
meta: {
headerTitle: "Интерфейс",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
]
if (!compactServer) {
cols.push({
id: "server",
accessorKey: "serverName",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
<Flag code={row.original.serverCountry || "UN"} size={12} />
{row.original.serverName}
</div>
),
meta: {
headerTitle: "Сервер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
})
}
cols.push(
{
id: "allowedIps",
accessorFn: (row) => row.allowedIps.join(", "),
header: ({ column }) => <DataGridSortHeader column={column} title="Allowed IPs" />,
cell: ({ row }) => (
<span className="block truncate font-mono text-xs text-muted-foreground">
{row.original.allowedIps.join(", ") || "—"}
</span>
),
meta: {
headerTitle: "Allowed IPs",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "handshake",
accessorKey: "latestHandshake",
header: ({ column }) => <DataGridSortHeader column={column} title="Handshake" />,
cell: ({ row }) => (
<span
className={cn(
"whitespace-nowrap font-mono text-[11px]",
row.original.latestHandshake ? "text-success" : "text-muted-foreground",
)}
>
{row.original.latestHandshake ?? "нет рукопожатия"}
</span>
),
meta: {
headerTitle: "Handshake",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "transfer",
header: () => (
<span className="text-xs font-medium text-muted-foreground">RX / TX</span>
),
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-success" />
{fmtBytes(row.original.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-info" />
{fmtBytes(row.original.transferTx)}
</span>
</div>
),
meta: {
headerTitle: "RX / TX",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "endpoint",
accessorKey: "endpoint",
header: ({ column }) => <DataGridSortHeader column={column} title="Endpoint" />,
cell: ({ row }) => (
<span className="font-mono text-[11px] text-muted-foreground">
{row.original.endpoint ?? "—"}
</span>
),
meta: {
headerTitle: "Endpoint",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
size: 72,
cell: ({ row }) => {
const peer = row.original
return (
<div className="flex justify-end gap-0.5">
{onExportPeer ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Экспорт peer .conf"
onClick={() => onExportPeer(peer)}
>
<CodeXmlIcon className="size-3.5" />
</Button>
) : null}
{onDeletePeer ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
aria-label="Удалить пира"
onClick={() => onDeletePeer(peer)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
</div>
)
},
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
)
return cols
}, [compactServer, onDeletePeer, onExportPeer])
const table = useReactTable({
data: peers,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (peers.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
title="Нет пиров"
description="Добавьте пира к интерфейсу или сбросьте поиск"
action={emptyAction}
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={peers.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row"),
}}
/>
)
}
export { WireguardPeersGrid, type WireguardPeersGridProps }
+236
View File
@@ -0,0 +1,236 @@
"use client"
import { useMemo, useState, type ReactNode } from "react"
import type { ServerStatus, ServerType } from "@/lib/data"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { cn } from "@/lib/utils"
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/preview/base/components/c-input-group-1 */
export const ALL_SERVERS_ID = "all"
export interface ServerTileItem {
id: string
name: string
country?: string
status?: ServerStatus
type?: ServerType
count?: number
enabled?: boolean
selectable?: boolean
title?: string
}
function typeBadgeVariant(
type: ServerType,
): "focus-light" | "info-light" | "success-light" {
if (type === "jump-host") return "focus-light"
if (type === "home-router") return "success-light"
return "info-light"
}
function typeBadgeLabel(type: ServerType): string {
if (type === "jump-host") return "JH"
if (type === "home-router") return "HR"
return "EN"
}
function ServerTypeBadge({ type }: { type: ServerType }) {
return (
<Badge variant={typeBadgeVariant(type)} size="xs" className="font-mono">
{typeBadgeLabel(type)}
</Badge>
)
}
function matchesQuery(item: ServerTileItem, q: string): boolean {
if (!q) return true
const hay = [item.name, item.title ?? ""].join(" ").toLowerCase()
return hay.includes(q)
}
function ServerTileRail({
items,
selectedId,
onSelect,
allCount = 0,
showHeader = true,
showAll = true,
showCount = true,
showType = true,
headerRight,
className,
}: {
items: ServerTileItem[]
selectedId: string
onSelect: (id: string) => void
allCount?: number
showHeader?: boolean
showAll?: boolean
showCount?: boolean
showType?: boolean
headerRight?: ReactNode
className?: string
}) {
const [query, setQuery] = useState("")
const q = query.trim().toLowerCase()
const filtered = useMemo(
() => items.filter((item) => matchesQuery(item, q)),
[items, q],
)
const showAllTile = showAll && !q
return (
<Frame dense spacing="sm" className={cn("flex h-full min-h-0 w-full flex-col", className)}>
<FramePanel className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<FrameHeader className="flex flex-col gap-2 border-b px-3 py-2">
{showHeader ? (
<div className="flex items-center justify-between gap-2">
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Серверы
</FrameTitle>
{headerRight}
</div>
) : headerRight ? (
<div className="flex justify-end">{headerRight}</div>
) : null}
<InputGroup>
<InputGroupAddon>
<SearchIcon className="size-3.5" />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск…"
aria-label="Поиск сервера"
/>
</InputGroup>
</FrameHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 p-1.5" role="listbox" aria-label="Серверы">
{showAllTile ? (
<ServerTileButton
selected={selectedId === ALL_SERVERS_ID}
onSelect={() => onSelect(ALL_SERVERS_ID)}
title="Все серверы"
name="Все"
count={showCount ? allCount : undefined}
icon={
<IconTile variant="elevated" size="sm" aria-hidden="true">
<LayersIcon className="text-muted-foreground" />
</IconTile>
}
/>
) : null}
{filtered.map((item) => (
<ServerTileButton
key={item.id}
selected={selectedId === item.id}
onSelect={() => onSelect(item.id)}
title={item.title ?? item.name}
name={item.name}
count={showCount ? item.count : undefined}
enabled={item.enabled}
selectable={item.selectable}
status={item.status}
type={showType ? item.type : undefined}
icon={
<IconTile variant="elevated" size="sm" aria-hidden="true">
{item.country ? (
<Flag code={item.country} size={16} />
) : (
<ServerIcon className="text-muted-foreground" />
)}
</IconTile>
}
/>
))}
{filtered.length === 0 && !showAllTile ? (
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
{items.length === 0 ? "Нет доступных серверов" : "Ничего не найдено"}
</p>
) : null}
</div>
</ScrollArea>
</FramePanel>
</Frame>
)
}
function ServerTileButton({
selected,
onSelect,
title,
name,
count,
enabled = true,
selectable = true,
status,
type,
icon,
}: {
selected: boolean
onSelect: () => void
title: string
name: string
count?: number
enabled?: boolean
selectable?: boolean
status?: ServerStatus
type?: ServerType
icon: ReactNode
}) {
return (
<button
type="button"
role="option"
aria-selected={selected}
title={title}
disabled={!selectable}
onClick={onSelect}
className={cn(
"flex h-11 w-full items-center gap-2 rounded-md px-2 text-left transition-colors",
selected
? "bg-muted ring-1 ring-border"
: "hover:bg-muted/60",
!enabled && !selected && "opacity-40",
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
)}
>
{icon}
<span className="flex min-w-0 flex-1 items-center gap-1.5">
{status ? <StatusDot status={status} /> : null}
<span className="min-w-0 truncate font-mono text-[11px] font-medium">{name}</span>
{type ? <ServerTypeBadge type={type} /> : null}
</span>
{count != null ? (
<Badge
variant={selected ? "secondary" : "outline"}
size="xs"
className="tabular-nums"
>
{count}
</Badge>
) : null}
</button>
)
}
export { ServerTileRail, ServerTypeBadge }
+9 -8
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -51,30 +51,31 @@ function WgCreateSheet({
onOpenChange,
servers,
busy,
defaultServerId,
onSubmit,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
defaultServerId?: string
onSubmit: (form: WgCreateFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm)
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open) return
setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
}, [open, defaultServerId])
const canSubmit = useMemo(() => {
return Boolean(form.serverId && form.name.trim() && form.listenPort)
}, [form.serverId, form.name, form.listenPort])
return (
<Sheet
open={open}
onOpenChange={(v) => {
if (v) setForm(defaultWgCreateForm())
onOpenChange(v)
}}
>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
+11 -2
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import {
@@ -17,12 +17,14 @@ function WgImportSheet({
onOpenChange,
servers,
busy,
defaultServerId,
onImport,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
defaultServerId?: string
onImport: (args: {
serverId: string
content: string
@@ -41,6 +43,11 @@ function WgImportSheet({
[content],
)
useEffect(() => {
if (!open) return
setServerId(defaultServerId ?? "")
}, [open, defaultServerId])
function runPreview() {
setParseError(null)
setPreview(null)
@@ -66,7 +73,9 @@ function WgImportSheet({
<Sheet
open={open}
onOpenChange={(v) => {
if (!v) {
if (v) {
setServerId(defaultServerId ?? "")
} else {
setContent("")
setPreview(null)
setParseError(null)