Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6123660346 |
+147
-98
@@ -4,11 +4,30 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { servers as mockServers } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import type { ServerStatus } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||
Frame,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
ServerTileRail,
|
||||
type ServerTileItem,
|
||||
} from "@/components/server-tile-rail"
|
||||
import {
|
||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, ServerIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -437,6 +456,7 @@ export default function TerminalPage() {
|
||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||
const [serversLoading, setServersLoading] = useState(false)
|
||||
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
||||
const [railOpen, setRailOpen] = useState(false)
|
||||
|
||||
// Load servers from backend when in live mode
|
||||
useEffect(() => {
|
||||
@@ -492,6 +512,79 @@ export default function TerminalPage() {
|
||||
|
||||
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
|
||||
|
||||
const railItems = useMemo<ServerTileItem[]>(() => {
|
||||
return termServers.map((s) => ({
|
||||
id: s.uid,
|
||||
name: s.name,
|
||||
country: s.country || undefined,
|
||||
status: (s.status ?? undefined) as ServerStatus | undefined,
|
||||
enabled: s.enabled,
|
||||
selectable: s.enabled && s.status !== "offline",
|
||||
title: [s.name, s.host].filter(Boolean).join(" · "),
|
||||
}))
|
||||
}, [termServers])
|
||||
|
||||
function handleSelectServer(id: string) {
|
||||
setSelectedUid(id)
|
||||
setRefreshKey((k) => k + 1)
|
||||
setRailOpen(false)
|
||||
}
|
||||
|
||||
const railHeaderRight = isLive
|
||||
? serversLoading
|
||||
? <Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
|
||||
: <Badge variant="success-light" size="xs">LIVE</Badge>
|
||||
: undefined
|
||||
|
||||
const rail = (
|
||||
<ServerTileRail
|
||||
items={railItems}
|
||||
selectedId={selected?.uid ?? selectedUid}
|
||||
onSelect={handleSelectServer}
|
||||
showAll={false}
|
||||
showCount={false}
|
||||
showType={false}
|
||||
headerRight={railHeaderRight}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
)
|
||||
|
||||
function QuickCmds() {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="min-h-0 shrink-0">
|
||||
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
|
||||
<FrameHeader className="border-b px-3 py-2">
|
||||
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Быстрые команды
|
||||
</FrameTitle>
|
||||
</FrameHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-0.5 p-1.5">
|
||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
||||
<button
|
||||
key={cmd}
|
||||
type="button"
|
||||
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => injectCommand(cmd)}
|
||||
title={cmd}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
|
||||
<p>↑↓ — история команд</p>
|
||||
<p>Ctrl+L — очистить экран</p>
|
||||
{isLive
|
||||
? <p className="text-info">Команды выполняются на роутере</p>
|
||||
: <p>Режим: mock-данные</p>}
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function injectCommand(cmd: string) {
|
||||
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
||||
if (!el) return
|
||||
@@ -502,108 +595,42 @@ export default function TerminalPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => {
|
||||
setRefreshKey(k => k + 1)
|
||||
if (isLive) setSelectedUid("")
|
||||
}}>
|
||||
<RefreshCwIcon className="size-4" />Переподключить
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="md:hidden"
|
||||
onClick={() => setRailOpen(true)}
|
||||
>
|
||||
<ServerIcon className="size-4" />
|
||||
{selected?.name ?? "Сервер"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRefreshKey((k) => k + 1)
|
||||
if (isLive) setSelectedUid("")
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Переподключить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="hidden min-h-0 w-60 shrink-0 flex-col gap-3 p-3 pr-0 md:flex">
|
||||
{rail}
|
||||
<QuickCmds />
|
||||
</aside>
|
||||
|
||||
{/* ── sidebar ── */}
|
||||
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
|
||||
|
||||
{/* server picker */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
|
||||
{isLive && serversLoading && (
|
||||
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{isLive && !serversLoading && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
|
||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLive && !serversLoading && liveServers.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2.5">
|
||||
Нет доступных серверов
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{termServers.map(s => {
|
||||
const isOffline = s.status === "offline"
|
||||
const isSelected = s.uid === selectedUid
|
||||
return (
|
||||
<button
|
||||
key={s.uid}
|
||||
disabled={isOffline || !s.enabled}
|
||||
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
|
||||
"flex items-center gap-2",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted",
|
||||
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block size-1.5 rounded-full shrink-0",
|
||||
s.status === "online" ? "bg-emerald-500" :
|
||||
s.status === "degraded" ? "bg-amber-400" :
|
||||
s.status === null ? "bg-sky-400" : "bg-red-500",
|
||||
)} />
|
||||
{s.country && <Flag code={s.country} />}
|
||||
<span className="truncate font-mono">{s.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* quick commands */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Быстрые команды
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
||||
<button
|
||||
key={cmd}
|
||||
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
|
||||
onClick={() => injectCommand(cmd)}
|
||||
title={cmd}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* hints */}
|
||||
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
|
||||
<p>↑↓ — история команд</p>
|
||||
<p>Ctrl+L — очистить экран</p>
|
||||
{isLive
|
||||
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
|
||||
: <p>Режим: mock-данные</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── terminal ── */}
|
||||
<div className="min-w-0 flex-1 overflow-hidden p-3 md:p-4">
|
||||
{selected ? (
|
||||
<Terminal
|
||||
key={termKey}
|
||||
@@ -612,12 +639,34 @@ export default function TerminalPage() {
|
||||
backendUrl={backendUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
|
||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={railOpen} onOpenChange={setRailOpen}>
|
||||
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
|
||||
<SheetHeader className="px-1 pt-1">
|
||||
<SheetTitle>Серверы</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<ServerTileRail
|
||||
items={railItems}
|
||||
selectedId={selected?.uid ?? selectedUid}
|
||||
onSelect={handleSelectServer}
|
||||
showAll={false}
|
||||
showCount={false}
|
||||
showType={false}
|
||||
showHeader={false}
|
||||
headerRight={railHeaderRight}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
<QuickCmds />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+428
-139
@@ -10,6 +10,10 @@ import {
|
||||
WireguardDataGrid,
|
||||
type WgIfaceWithServer,
|
||||
} from "@/components/data-grids/wireguard-data-grid"
|
||||
import {
|
||||
WireguardPeersGrid,
|
||||
type WgPeerRow,
|
||||
} from "@/components/data-grids/wireguard-peers-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Alert,
|
||||
@@ -18,6 +22,24 @@ import {
|
||||
} from "@/components/reui/alert"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import {
|
||||
@@ -35,12 +57,25 @@ import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg
|
||||
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
|
||||
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
|
||||
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
|
||||
import {
|
||||
ALL_SERVERS_ID,
|
||||
ServerTileRail,
|
||||
type ServerTileItem,
|
||||
} from "@/components/server-tile-rail"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||||
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
||||
ServerIcon, Trash2Icon, CodeXmlIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
|
||||
type WgStatusFilter = "all" | "up" | "down"
|
||||
type WgFailure = { serverId: string; serverName?: string; error: string }
|
||||
type PendingDelete =
|
||||
| { kind: "iface"; iface: WgIfaceWithServer }
|
||||
| { kind: "peer"; iface: WgIfaceWithServer; peerId: string }
|
||||
|
||||
function collectMockInterfaces(): WgIfaceWithServer[] {
|
||||
const result: WgIfaceWithServer[] = []
|
||||
for (const srv of mockServers) {
|
||||
@@ -98,7 +133,11 @@ interface BackendServer {
|
||||
name: string
|
||||
host: string
|
||||
country: string
|
||||
type?: Server["type"]
|
||||
enabled: boolean
|
||||
status?: Server["status"]
|
||||
latency?: number | null
|
||||
asn?: string
|
||||
}
|
||||
|
||||
function mapBackendServer(s: BackendServer): Server {
|
||||
@@ -110,11 +149,11 @@ function mapBackendServer(s: BackendServer): Server {
|
||||
os: "—",
|
||||
site: "",
|
||||
country: s.country || "UN",
|
||||
asn: "",
|
||||
type: "exit-node",
|
||||
asn: s.asn ?? "",
|
||||
type: s.type ?? "exit-node",
|
||||
enabled: s.enabled,
|
||||
status: "online",
|
||||
latency: null,
|
||||
status: s.status ?? "online",
|
||||
latency: s.latency ?? null,
|
||||
sessions: 0,
|
||||
}
|
||||
}
|
||||
@@ -130,15 +169,24 @@ function parseEndpoint(endpoint: string): { address?: string; port?: number } {
|
||||
}
|
||||
}
|
||||
|
||||
function peerRowId(iface: WgIfaceWithServer, peer: WgIfaceWithServer["peers"][number], index: number): string {
|
||||
return peer.id ?? peer.rosId ?? `${iface.id}-${peer.publicKey}-${index}`
|
||||
}
|
||||
|
||||
export default function WireGuardPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
const [failures, setFailures] = useState<WgFailure[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
const [workspaceTab, setWorkspaceTab] = useState<WgWorkspaceTab>("interfaces")
|
||||
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("all")
|
||||
const [railOpen, setRailOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
@@ -146,6 +194,7 @@ export default function WireGuardPage() {
|
||||
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
|
||||
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
|
||||
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
||||
const [liveExport, setLiveExport] = useState<{
|
||||
rsc?: string
|
||||
conf?: string
|
||||
@@ -163,6 +212,7 @@ export default function WireGuardPage() {
|
||||
])
|
||||
setLiveIfaces(wg.interfaces.map(dtoToRow))
|
||||
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
||||
setFailures(wg.failures ?? [])
|
||||
if (wg.failures?.length) {
|
||||
toast.warning(
|
||||
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
|
||||
@@ -171,6 +221,7 @@ export default function WireGuardPage() {
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
|
||||
setLiveIfaces([])
|
||||
setFailures([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -181,6 +232,7 @@ export default function WireGuardPage() {
|
||||
queueMicrotask(() => {
|
||||
setLiveIfaces([])
|
||||
setLiveServers([])
|
||||
setFailures([])
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -192,10 +244,35 @@ export default function WireGuardPage() {
|
||||
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
|
||||
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return displayIfaces
|
||||
const effectiveServerId =
|
||||
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||
? selectedServerId
|
||||
: ALL_SERVERS_ID
|
||||
|
||||
const scopedIfaces = useMemo(() => {
|
||||
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
|
||||
return displayIfaces.filter((i) => i.serverId === effectiveServerId)
|
||||
}, [displayIfaces, effectiveServerId])
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
let up = 0
|
||||
let down = 0
|
||||
for (const iface of scopedIfaces) {
|
||||
if (iface.status === "up") up += 1
|
||||
else down += 1
|
||||
}
|
||||
return { all: scopedIfaces.length, up, down }
|
||||
}, [scopedIfaces])
|
||||
|
||||
const statusFiltered = useMemo(() => {
|
||||
if (statusFilter === "all") return scopedIfaces
|
||||
return scopedIfaces.filter((i) => i.status === statusFilter)
|
||||
}, [scopedIfaces, statusFilter])
|
||||
|
||||
const filteredIfaces = useMemo(() => {
|
||||
if (!search) return statusFiltered
|
||||
const q = search.toLowerCase()
|
||||
return displayIfaces.filter(
|
||||
return statusFiltered.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(q) ||
|
||||
i.serverName.toLowerCase().includes(q) ||
|
||||
@@ -205,14 +282,47 @@ export default function WireGuardPage() {
|
||||
(p.endpoint ?? "").includes(q),
|
||||
),
|
||||
)
|
||||
}, [displayIfaces, search])
|
||||
}, [statusFiltered, search])
|
||||
|
||||
const totalPeers = displayIfaces.reduce((s, i) => s + i.peers.length, 0)
|
||||
const onlinePeers = displayIfaces.reduce(
|
||||
const peerRows = useMemo<WgPeerRow[]>(() => {
|
||||
const q = search.toLowerCase()
|
||||
const rows: WgPeerRow[] = []
|
||||
for (const iface of scopedIfaces) {
|
||||
iface.peers.forEach((peer, index) => {
|
||||
const id = peerRowId(iface, peer, index)
|
||||
if (q) {
|
||||
const hay = [
|
||||
peer.publicKey,
|
||||
peer.name ?? "",
|
||||
peer.endpoint ?? "",
|
||||
peer.allowedIps.join(" "),
|
||||
iface.name,
|
||||
iface.serverName,
|
||||
].join(" ").toLowerCase()
|
||||
if (!hay.includes(q)) return
|
||||
}
|
||||
rows.push({
|
||||
...peer,
|
||||
id,
|
||||
ifaceId: iface.id,
|
||||
ifaceName: iface.name,
|
||||
serverId: iface.serverId,
|
||||
serverName: iface.serverName,
|
||||
serverCountry: iface.serverCountry,
|
||||
})
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}, [scopedIfaces, search])
|
||||
|
||||
const totalPeers = scopedIfaces.reduce((s, i) => s + i.peers.length, 0)
|
||||
const onlinePeers = scopedIfaces.reduce(
|
||||
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length,
|
||||
0,
|
||||
)
|
||||
const upIfaces = displayIfaces.filter((i) => i.status === "up").length
|
||||
const upIfaces = scopedIfaces.filter((i) => i.status === "up").length
|
||||
const compactServer = effectiveServerId !== ALL_SERVERS_ID
|
||||
const sheetServerId = compactServer ? effectiveServerId : undefined
|
||||
|
||||
const serverOptions = displayServers.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -220,6 +330,33 @@ export default function WireGuardPage() {
|
||||
host: s.host,
|
||||
}))
|
||||
|
||||
const railItems = useMemo<ServerTileItem[]>(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const iface of displayIfaces) {
|
||||
counts.set(iface.serverId, (counts.get(iface.serverId) ?? 0) + 1)
|
||||
}
|
||||
return displayServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
type: s.type,
|
||||
count: counts.get(s.id) ?? 0,
|
||||
enabled: s.enabled,
|
||||
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||||
}))
|
||||
}, [displayServers, displayIfaces])
|
||||
|
||||
const selectedLabel =
|
||||
effectiveServerId === ALL_SERVERS_ID
|
||||
? "Все серверы"
|
||||
: (displayServers.find((s) => s.id === effectiveServerId)?.name ?? "Сервер")
|
||||
|
||||
function handleSelectServer(id: string) {
|
||||
setSelectedServerId(id)
|
||||
setRailOpen(false)
|
||||
}
|
||||
|
||||
async function handleCreate(form: WgCreateFormState) {
|
||||
if (!isLive) {
|
||||
toast.info("Создание на роутер доступно только в live-режиме")
|
||||
@@ -308,15 +445,26 @@ export default function WireGuardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(iface: WgIfaceWithServer) {
|
||||
if (!isLive || !iface.rosId) {
|
||||
async function confirmDelete() {
|
||||
if (!pendingDelete) return
|
||||
if (!isLive) {
|
||||
toast.info("Доступно только в live-режиме")
|
||||
setPendingDelete(null)
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`Удалить интерфейс ${iface.name} на ${iface.serverName}?`)) return
|
||||
try {
|
||||
await deleteWireGuardInterface(backendUrl, iface.serverId, iface.rosId)
|
||||
toast.success("Удалено")
|
||||
if (pendingDelete.kind === "iface") {
|
||||
if (!pendingDelete.iface.rosId) {
|
||||
toast.info("Доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId)
|
||||
toast.success("Удалено")
|
||||
} else {
|
||||
await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, pendingDelete.peerId)
|
||||
toast.success("Пир удалён")
|
||||
}
|
||||
setPendingDelete(null)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка удаления")
|
||||
@@ -354,21 +502,6 @@ export default function WireGuardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePeer(iface: WgIfaceWithServer, peerId: string) {
|
||||
if (!isLive) {
|
||||
toast.info("Доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
if (!window.confirm("Удалить пира?")) return
|
||||
try {
|
||||
await deleteWireGuardPeer(backendUrl, iface.serverId, peerId)
|
||||
toast.success("Пир удалён")
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
|
||||
if (!exportIface || !isLive) return
|
||||
setExportBusy(true)
|
||||
@@ -403,12 +536,37 @@ export default function WireGuardPage() {
|
||||
setExportIface(iface)
|
||||
}
|
||||
|
||||
const emptyCreateAction = (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon className="size-4" />
|
||||
Новый интерфейс
|
||||
</Button>
|
||||
)
|
||||
|
||||
const rail = (
|
||||
<ServerTileRail
|
||||
items={railItems}
|
||||
selectedId={effectiveServerId}
|
||||
onSelect={handleSelectServer}
|
||||
allCount={displayIfaces.length}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="md:hidden"
|
||||
onClick={() => setRailOpen(true)}
|
||||
>
|
||||
<ServerIcon className="size-4" />
|
||||
{selectedLabel}
|
||||
</Button>
|
||||
{isLive && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -432,123 +590,227 @@ export default function WireGuardPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка WireGuard"
|
||||
items={[
|
||||
{
|
||||
id: "ifaces",
|
||||
label: "Интерфейсов",
|
||||
value: displayIfaces.length,
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Активных (UP)",
|
||||
value: upIfaces,
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "peers",
|
||||
label: "Всего пиров",
|
||||
value: totalPeers,
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "online",
|
||||
label: "Пиров онлайн",
|
||||
value: `${onlinePeers}/${totalPeers}`,
|
||||
icon: <KeyRoundIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="hidden min-h-0 w-60 shrink-0 p-3 pr-0 md:flex">
|
||||
{rail}
|
||||
</aside>
|
||||
|
||||
<Alert variant="info">
|
||||
<InfoIcon />
|
||||
<AlertTitle>WireGuard — live-интеграция RouterOS 7.x</AlertTitle>
|
||||
<AlertDescription>
|
||||
{isLive
|
||||
? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера."
|
||||
: "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, серверу, IP…"
|
||||
countLabel={`${filtered.length} интерфейсов`}
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка WireGuard"
|
||||
items={[
|
||||
{
|
||||
id: "ifaces",
|
||||
label: "Интерфейсов",
|
||||
value: scopedIfaces.length,
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Активных (UP)",
|
||||
value: upIfaces,
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "peers",
|
||||
label: "Всего пиров",
|
||||
value: totalPeers,
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "online",
|
||||
label: "Пиров онлайн",
|
||||
value: `${onlinePeers}/${totalPeers}`,
|
||||
icon: <KeyRoundIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<WireguardDataGrid
|
||||
interfaces={filtered}
|
||||
onExport={(iface) => openExport(iface, "rsc")}
|
||||
onAddPeer={setPeerIface}
|
||||
onToggle={handleToggle}
|
||||
onDelete={handleDelete}
|
||||
onDeletePeer={handleDeletePeer}
|
||||
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
|
||||
/>
|
||||
</DataPageCard>
|
||||
|
||||
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||
{[
|
||||
{
|
||||
title: "Создать интерфейс",
|
||||
lines: [
|
||||
"/interface wireguard add \\",
|
||||
" name=wg0 \\",
|
||||
" listen-port=13231 \\",
|
||||
" mtu=1420",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Добавить пира",
|
||||
lines: [
|
||||
"/interface wireguard peers add \\",
|
||||
" interface=wg0 \\",
|
||||
' public-key="<ключ>" \\',
|
||||
" allowed-address=10.0.0.2/32 \\",
|
||||
" endpoint-address=1.2.3.4 \\",
|
||||
" persistent-keepalive=25",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Назначить IP",
|
||||
lines: [
|
||||
"/ip address add \\",
|
||||
" address=10.210.0.1/30 \\",
|
||||
" interface=wg0",
|
||||
"",
|
||||
"# Статус:",
|
||||
"/interface wireguard print",
|
||||
],
|
||||
},
|
||||
].map((b) => (
|
||||
<div key={b.title}>
|
||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
|
||||
{b.title}
|
||||
</p>
|
||||
<pre className="bg-muted rounded-md p-2.5 text-muted-foreground text-[11px] leading-relaxed overflow-x-auto">
|
||||
{b.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</OpsPanel>
|
||||
{failures.length > 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
|
||||
<AlertDescription>
|
||||
{failures.map((f) => f.serverName ?? f.serverId).join(", ")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!isLive ? (
|
||||
<Alert variant="info">
|
||||
<InfoIcon />
|
||||
<AlertTitle>Mock-режим</AlertTitle>
|
||||
<AlertDescription>
|
||||
Переключитесь в live в настройках, чтобы применять изменения на MikroTik.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Tabs
|
||||
value={workspaceTab}
|
||||
onValueChange={(v) => setWorkspaceTab(v as WgWorkspaceTab)}
|
||||
className="gap-3"
|
||||
>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="interfaces" className="gap-1.5">
|
||||
<ShieldCheckIcon className="size-3.5" />
|
||||
Интерфейсы
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="peers" className="gap-1.5">
|
||||
<UsersIcon className="size-3.5" />
|
||||
Пиры
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cli" className="gap-1.5">
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
CLI
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="interfaces" className="mt-0 outline-none">
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, серверу, IP…"
|
||||
countLabel={`${filteredIfaces.length} интерфейсов`}
|
||||
segmented={{
|
||||
value: statusFilter,
|
||||
onChange: setStatusFilter,
|
||||
options: [
|
||||
{ value: "all", label: "Все", count: statusCounts.all },
|
||||
{ value: "up", label: "UP", count: statusCounts.up },
|
||||
{ value: "down", label: "DOWN", count: statusCounts.down },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<WireguardDataGrid
|
||||
interfaces={filteredIfaces}
|
||||
compactServer={compactServer}
|
||||
emptyAction={emptyCreateAction}
|
||||
onExport={(iface) => openExport(iface, "rsc")}
|
||||
onAddPeer={setPeerIface}
|
||||
onToggle={handleToggle}
|
||||
onDelete={(iface) => setPendingDelete({ kind: "iface", iface })}
|
||||
onDeletePeer={(iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
|
||||
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="peers" className="mt-0 outline-none">
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по ключу, IP, endpoint…"
|
||||
countLabel={`${peerRows.length} пиров`}
|
||||
/>
|
||||
<WireguardPeersGrid
|
||||
peers={peerRows}
|
||||
compactServer={compactServer}
|
||||
emptyAction={
|
||||
scopedIfaces.length === 1 ? (
|
||||
<Button size="sm" onClick={() => setPeerIface(scopedIfaces[0])}>
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
) : emptyCreateAction
|
||||
}
|
||||
onDeletePeer={(row) => {
|
||||
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
|
||||
if (!iface) return
|
||||
setPendingDelete({ kind: "peer", iface, peerId: row.id })
|
||||
}}
|
||||
onExportPeer={(row) => {
|
||||
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
|
||||
if (!iface) return
|
||||
openExport(iface, "peer", row.id)
|
||||
}}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cli" className="mt-0 outline-none">
|
||||
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
|
||||
<div className="grid grid-cols-1 gap-4 font-mono text-xs sm:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
title: "Создать интерфейс",
|
||||
lines: [
|
||||
"/interface wireguard add \\",
|
||||
" name=wg0 \\",
|
||||
" listen-port=13231 \\",
|
||||
" mtu=1420",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Добавить пира",
|
||||
lines: [
|
||||
"/interface wireguard peers add \\",
|
||||
" interface=wg0 \\",
|
||||
' public-key="<ключ>" \\',
|
||||
" allowed-address=10.0.0.2/32 \\",
|
||||
" endpoint-address=1.2.3.4 \\",
|
||||
" persistent-keepalive=25",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Назначить IP",
|
||||
lines: [
|
||||
"/ip address add \\",
|
||||
" address=10.210.0.1/30 \\",
|
||||
" interface=wg0",
|
||||
"",
|
||||
"# Статус:",
|
||||
"/interface wireguard print",
|
||||
],
|
||||
},
|
||||
].map((b) => (
|
||||
<div key={b.title}>
|
||||
<p className="mb-1.5 font-sans text-[11px] font-semibold uppercase tracking-wide text-foreground/80">
|
||||
{b.title}
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted p-2.5 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{b.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</OpsPanel>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={railOpen} onOpenChange={setRailOpen}>
|
||||
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
|
||||
<SheetHeader className="px-1 pt-1">
|
||||
<SheetTitle>Серверы</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ServerTileRail
|
||||
items={railItems}
|
||||
selectedId={effectiveServerId}
|
||||
onSelect={handleSelectServer}
|
||||
allCount={displayIfaces.length}
|
||||
showHeader={false}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<WgCreateSheet
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
servers={serverOptions}
|
||||
defaultServerId={sheetServerId}
|
||||
busy={busy}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
@@ -556,6 +818,7 @@ export default function WireGuardPage() {
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
servers={serverOptions}
|
||||
defaultServerId={sheetServerId}
|
||||
busy={busy}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
@@ -581,6 +844,32 @@ export default function WireGuardPage() {
|
||||
liveBusy={exportBusy}
|
||||
onRequestLiveExport={isLive ? handleLiveExport : undefined}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<Trash2Icon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>
|
||||
{pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDelete?.kind === "iface"
|
||||
? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры на роутере.`
|
||||
: pendingDelete
|
||||
? `Пир на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).`
|
||||
: null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => void confirmDelete()}>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user