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
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 2m45s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 10s
Updated the TerminalPage to include a new ServerTileRail for server selection and added QuickCmds for quick command execution. Enhanced the WireGuardPage with new tabs for interface and peer management, improved server selection handling, and added support for displaying peer details. Refactored data grids to support compact server views and improved empty state handling. Introduced new hooks for managing server states and improved user experience across components.
This commit is contained in:
+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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user