Compare commits

..
1 Commits
Author SHA1 Message Date
Denozordec 6123660346 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.
2026-09-06 01:23:40 +07:00
9 changed files with 1123 additions and 260 deletions
+147 -98
View File
@@ -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
View File
@@ -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>
)
}
+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)
+1 -1
View File
File diff suppressed because one or more lines are too long