141 lines
5.5 KiB
TypeScript
141 lines
5.5 KiB
TypeScript
"use client"
|
||
|
||
import { useState } from "react"
|
||
import type { WireGuardPeer } from "@/lib/data"
|
||
import { cn } from "@/lib/utils"
|
||
import { Button } from "@/components/ui/button"
|
||
import {
|
||
ArrowDownIcon,
|
||
ArrowUpIcon,
|
||
CodeXmlIcon,
|
||
KeyRoundIcon,
|
||
Trash2Icon,
|
||
} from "lucide-react"
|
||
|
||
function fmtBytes(n: number | undefined): string {
|
||
if (n === undefined || !Number.isFinite(n) || n < 0) return "—"
|
||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
|
||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
|
||
return `${n} Б`
|
||
}
|
||
|
||
function truncKey(key: string): string {
|
||
if (key.length <= 20) return key
|
||
return `${key.slice(0, 8)}…${key.slice(-8)}`
|
||
}
|
||
|
||
function peerKey(peer: WireGuardPeer, index: number): string {
|
||
return peer.id ?? peer.rosId ?? peer.publicKey ?? String(index)
|
||
}
|
||
|
||
function WireGuardPeersDetail({
|
||
peers,
|
||
interfaceName,
|
||
onDeletePeer,
|
||
onExportPeer,
|
||
}: {
|
||
peers: WireGuardPeer[]
|
||
interfaceName: string
|
||
onDeletePeer?: (peerId: string) => void
|
||
onExportPeer?: (peerId: string) => void
|
||
}) {
|
||
const [page, setPage] = useState(0)
|
||
const lastPage = Math.max(0, Math.ceil(peers.length / 50) - 1)
|
||
const currentPage = Math.min(page, lastPage)
|
||
const offset = currentPage * 50
|
||
if (page > lastPage) setPage(lastPage)
|
||
if (peers.length === 0) {
|
||
return (
|
||
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
|
||
Нет пиров
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<section aria-label={"Пиры: " + interfaceName} className="sticky left-0 w-full max-w-[calc(100vw-2rem)] border-t border-border/50 md:max-w-none">
|
||
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-2 bg-muted/10">
|
||
<p className="text-xs text-muted-foreground" aria-live="polite">Пиры {offset + 1}–{Math.min(offset + 50, peers.length)} из {peers.length}</p>
|
||
{peers.length > 50 && <div className="flex gap-1">
|
||
<Button type="button" variant="outline" className="min-h-11" disabled={currentPage === 0} aria-label="Предыдущая страница пиров" onClick={() => setPage(currentPage - 1)}>Назад</Button>
|
||
<Button type="button" variant="outline" className="min-h-11" disabled={currentPage >= lastPage} aria-label="Следующая страница пиров" onClick={() => setPage(currentPage + 1)}>Далее</Button>
|
||
</div>}
|
||
</div>
|
||
{peers.slice(offset, offset + 50).map((peer, index) => {
|
||
const id = peerKey(peer, offset + index)
|
||
return (
|
||
<div
|
||
key={id}
|
||
data-wireguard-peer={id}
|
||
className="grid grid-cols-2 gap-3 px-4 py-3 items-center text-xs border-t border-border/50 bg-muted/20 md:grid-cols-[1fr_1fr_auto_auto_auto_auto]"
|
||
>
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||
<span className="font-mono text-muted-foreground break-all" title={peer.publicKey}>
|
||
{peer.name || truncKey(peer.publicKey)}
|
||
</span>
|
||
</div>
|
||
<div className="min-w-0 break-all font-mono text-muted-foreground">
|
||
<span className="sr-only">Allowed IPs: </span><span>{peer.allowedIps.join(", ")}</span>
|
||
</div>
|
||
<span
|
||
className={cn(
|
||
"font-mono text-[11px] whitespace-nowrap",
|
||
"text-muted-foreground",
|
||
)}
|
||
>
|
||
<span className="sr-only">Последнее рукопожатие: </span><span>{peer.latestHandshake?.trim() || "Нет данных"}</span>
|
||
</span>
|
||
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
||
<span aria-label="RX" className="flex items-center gap-0.5">
|
||
<ArrowDownIcon aria-hidden="true" className="size-3 text-success" />
|
||
{fmtBytes(peer.transferRx)}
|
||
</span>
|
||
<span aria-label="TX" className="flex items-center gap-0.5">
|
||
<ArrowUpIcon aria-hidden="true" className="size-3 text-info" />
|
||
{fmtBytes(peer.transferTx)}
|
||
</span>
|
||
</div>
|
||
<span className="min-w-0 break-all font-mono text-muted-foreground/60 text-[11px]"><span>Endpoint: </span>{peer.endpoint ?? "—"}</span>
|
||
<div className="flex items-center gap-1 justify-end">
|
||
{onExportPeer && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-11"
|
||
aria-label="Экспорт peer .conf"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onExportPeer(id)
|
||
}}
|
||
>
|
||
<CodeXmlIcon className="size-3.5" />
|
||
</Button>
|
||
)}
|
||
{onDeletePeer && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-11 text-destructive"
|
||
aria-label="Удалить пира"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onDeletePeer(id)
|
||
}}
|
||
>
|
||
<Trash2Icon className="size-3.5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export { WireGuardPeersDetail, fmtBytes, truncKey }
|