Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
828 lines
28 KiB
TypeScript
828 lines
28 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
import { PageHeader } from "@/components/page-header"
|
|
import { servers as mockServers } from "@/lib/data"
|
|
import type { Server } from "@/lib/data"
|
|
import { DataPageCard } from "@/components/data-page-card"
|
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
|
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,
|
|
AlertDescription,
|
|
AlertTitle,
|
|
} 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 {
|
|
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 {
|
|
createWireGuardInterface,
|
|
createWireGuardPeer,
|
|
deleteWireGuardInterface,
|
|
deleteWireGuardPeer,
|
|
exportWireGuard,
|
|
importWireGuard,
|
|
listWireGuard,
|
|
patchWireGuardInterface,
|
|
} from "@/shared/api/wireguard"
|
|
import type { WgIfaceDto } from "@mmapp/contracts/wireguard"
|
|
import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg-create-sheet"
|
|
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 { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
|
import {
|
|
ALL_SERVERS_ID,
|
|
type ServerTileItem,
|
|
} from "@/components/server-tile-rail"
|
|
import { toast } from "sonner"
|
|
import {
|
|
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
|
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
|
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) {
|
|
for (const wg of srv.wireGuardIfaces ?? []) {
|
|
result.push({
|
|
...wg,
|
|
serverId: srv.id,
|
|
serverName: srv.name,
|
|
serverCountry: srv.country,
|
|
})
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function dtoToRow(d: WgIfaceDto): WgIfaceWithServer {
|
|
return {
|
|
id: d.id,
|
|
rosId: d.rosId,
|
|
name: d.name,
|
|
listenPort: d.listenPort,
|
|
mtu: d.mtu,
|
|
publicKey: d.publicKey,
|
|
privateKey: d.privateKey,
|
|
address: d.address,
|
|
peers: d.peers.map((p) => ({
|
|
id: p.id,
|
|
rosId: p.rosId,
|
|
publicKey: p.publicKey,
|
|
allowedIps: p.allowedIps,
|
|
endpoint: p.endpoint,
|
|
latestHandshake: p.latestHandshake,
|
|
transferRx: p.transferRx,
|
|
transferTx: p.transferTx,
|
|
persistentKeepalive: p.persistentKeepalive,
|
|
persistent: p.persistent,
|
|
comment: p.comment,
|
|
disabled: p.disabled,
|
|
name: p.name,
|
|
clientAddress: p.clientAddress,
|
|
clientDns: p.clientDns,
|
|
clientEndpoint: p.clientEndpoint,
|
|
})),
|
|
comment: d.comment,
|
|
enabled: d.enabled,
|
|
status: d.status,
|
|
serverId: d.serverId,
|
|
serverName: d.serverName,
|
|
serverCountry: d.serverCountry ?? "UN",
|
|
}
|
|
}
|
|
|
|
interface BackendServer {
|
|
id: number
|
|
name: string
|
|
host: string
|
|
country: string
|
|
type?: Server["type"]
|
|
enabled: boolean
|
|
status?: Server["status"]
|
|
latency?: number | null
|
|
asn?: string
|
|
}
|
|
|
|
function mapBackendServer(s: BackendServer): Server {
|
|
return {
|
|
id: String(s.id),
|
|
name: s.name || s.host,
|
|
host: s.host,
|
|
model: "—",
|
|
os: "—",
|
|
site: "",
|
|
country: s.country || "UN",
|
|
asn: s.asn ?? "",
|
|
type: s.type ?? "exit-node",
|
|
enabled: s.enabled,
|
|
status: s.status ?? "online",
|
|
latency: s.latency ?? null,
|
|
sessions: 0,
|
|
}
|
|
}
|
|
|
|
function parseEndpoint(endpoint: string): { address?: string; port?: number } {
|
|
const t = endpoint.trim()
|
|
if (!t) return {}
|
|
const idx = t.lastIndexOf(":")
|
|
if (idx <= 0) return { address: t }
|
|
return {
|
|
address: t.slice(0, idx),
|
|
port: Number.parseInt(t.slice(idx + 1), 10) || undefined,
|
|
}
|
|
}
|
|
|
|
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 [search, setSearch] = useState("")
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
const [importOpen, setImportOpen] = useState(false)
|
|
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
|
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
|
|
peerConf?: string
|
|
} | null>(null)
|
|
const [exportBusy, setExportBusy] = useState(false)
|
|
|
|
const loadLive = useCallback(async () => {
|
|
if (!isLive) return
|
|
setLoading(true)
|
|
try {
|
|
const [wg, servers] = await Promise.all([
|
|
listWireGuard(backendUrl),
|
|
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
|
])
|
|
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(", ")}`,
|
|
)
|
|
}
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
|
|
setLiveIfaces([])
|
|
setFailures([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [isLive, backendUrl])
|
|
|
|
useEffect(() => {
|
|
if (!isLive) {
|
|
queueMicrotask(() => {
|
|
setLiveIfaces([])
|
|
setLiveServers([])
|
|
setFailures([])
|
|
})
|
|
return
|
|
}
|
|
queueMicrotask(() => {
|
|
void loadLive()
|
|
})
|
|
}, [isLive, loadLive])
|
|
|
|
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
|
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
|
|
|
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 statusFiltered.filter(
|
|
(i) =>
|
|
i.name.toLowerCase().includes(q) ||
|
|
i.serverName.toLowerCase().includes(q) ||
|
|
i.peers.some(
|
|
(p) =>
|
|
p.allowedIps.some((a) => a.includes(q)) ||
|
|
(p.endpoint ?? "").includes(q),
|
|
),
|
|
)
|
|
}, [statusFiltered, search])
|
|
|
|
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 = 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,
|
|
name: s.name,
|
|
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,
|
|
host: s.host,
|
|
site: s.site,
|
|
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])
|
|
|
|
async function handleCreate(form: WgCreateFormState) {
|
|
if (!isLive) {
|
|
toast.info("Создание на роутер доступно только в live-режиме")
|
|
return
|
|
}
|
|
setBusy(true)
|
|
try {
|
|
const ep = parseEndpoint(form.peerEndpoint)
|
|
await createWireGuardInterface(backendUrl, {
|
|
serverId: form.serverId,
|
|
name: form.name.trim(),
|
|
listenPort: Number.parseInt(form.listenPort, 10) || 13231,
|
|
mtu: Number.parseInt(form.mtu, 10) || 1420,
|
|
comment: form.comment || undefined,
|
|
address: form.address.trim() || undefined,
|
|
disabled: !form.enabled,
|
|
peer: form.peerEnabled && form.peerPublicKey.trim()
|
|
? {
|
|
publicKey: form.peerPublicKey.trim(),
|
|
allowedAddresses: form.peerAllowedIps
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean),
|
|
endpointAddress: ep.address,
|
|
endpointPort: ep.port,
|
|
persistentKeepalive: Number.parseInt(form.peerKeepalive, 10) || undefined,
|
|
comment: form.peerComment || undefined,
|
|
}
|
|
: undefined,
|
|
})
|
|
toast.success(`Интерфейс ${form.name} создан`)
|
|
setCreateOpen(false)
|
|
await loadLive()
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Ошибка создания")
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleImport(args: {
|
|
serverId: string
|
|
content: string
|
|
format: "auto" | "rsc" | "conf"
|
|
dryRun: boolean
|
|
}) {
|
|
if (!isLive) {
|
|
toast.info("Импорт на роутер доступен только в live-режиме")
|
|
return
|
|
}
|
|
setBusy(true)
|
|
try {
|
|
const res = await importWireGuard(backendUrl, {
|
|
serverId: args.serverId,
|
|
content: args.content,
|
|
format: args.format,
|
|
dryRun: args.dryRun,
|
|
})
|
|
toast.success(
|
|
res.applied
|
|
? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)`
|
|
: "Импорт выполнен",
|
|
)
|
|
setImportOpen(false)
|
|
await loadLive()
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Ошибка импорта")
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleToggle(iface: WgIfaceWithServer) {
|
|
if (!isLive || !iface.rosId) {
|
|
toast.info("Доступно только в live-режиме")
|
|
return
|
|
}
|
|
try {
|
|
await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, {
|
|
disabled: iface.enabled,
|
|
})
|
|
toast.success(iface.enabled ? "Отключено" : "Включено")
|
|
await loadLive()
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Ошибка")
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!pendingDelete) return
|
|
if (!isLive) {
|
|
toast.info("Доступно только в live-режиме")
|
|
setPendingDelete(null)
|
|
return
|
|
}
|
|
try {
|
|
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 : "Ошибка удаления")
|
|
}
|
|
}
|
|
|
|
async function handleAddPeer(form: WgPeerFormState) {
|
|
if (!isLive || !peerIface) {
|
|
toast.info("Доступно только в live-режиме")
|
|
return
|
|
}
|
|
setBusy(true)
|
|
try {
|
|
const ep = parseEndpoint(form.endpoint)
|
|
await createWireGuardPeer(backendUrl, {
|
|
serverId: peerIface.serverId,
|
|
interfaceName: peerIface.name,
|
|
publicKey: form.publicKey.trim(),
|
|
allowedAddresses: form.allowedIps
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean),
|
|
endpointAddress: ep.address,
|
|
endpointPort: ep.port,
|
|
persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined,
|
|
comment: form.comment || undefined,
|
|
})
|
|
toast.success("Пир добавлен")
|
|
setPeerIface(null)
|
|
await loadLive()
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Ошибка")
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
|
|
if (!exportIface || !isLive) return
|
|
setExportBusy(true)
|
|
try {
|
|
const res = await exportWireGuard(backendUrl, {
|
|
serverId: exportIface.serverId,
|
|
interfaceName: exportIface.name,
|
|
format,
|
|
includePrivateKey: format !== "peer-conf",
|
|
peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined,
|
|
})
|
|
setLiveExport((prev) => ({
|
|
...prev,
|
|
...(format === "rsc"
|
|
? { rsc: res.content }
|
|
: format === "conf"
|
|
? { conf: res.content }
|
|
: { peerConf: res.content }),
|
|
}))
|
|
toast.success("Конфиг загружен с роутера")
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Ошибка экспорта")
|
|
} finally {
|
|
setExportBusy(false)
|
|
}
|
|
}
|
|
|
|
function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) {
|
|
setLiveExport(null)
|
|
setExportInitialTab(tab)
|
|
setExportPeerId(peerId ?? null)
|
|
setExportIface(iface)
|
|
}
|
|
|
|
const emptyCreateAction = (
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
<PlusIcon className="size-4" />
|
|
Новый интерфейс
|
|
</Button>
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<ServerRailLayout
|
|
items={railItems}
|
|
selectedId={effectiveServerId}
|
|
onSelect={setSelectedServerId}
|
|
showAll
|
|
allCount={displayIfaces.length}
|
|
loading={isLive && loading && displayServers.length === 0}
|
|
header={
|
|
<PageHeader
|
|
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
|
actions={
|
|
<>
|
|
<ServerRailMobileButton />
|
|
{isLive && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={loading}
|
|
onClick={() => void loadLive()}
|
|
>
|
|
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
|
Обновить
|
|
</Button>
|
|
)}
|
|
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
|
<UploadIcon className="size-4" />
|
|
Импорт
|
|
</Button>
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
<PlusIcon className="size-4" />
|
|
Новый интерфейс
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
}
|
|
>
|
|
<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",
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{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>
|
|
</ServerRailLayout>
|
|
|
|
<WgCreateSheet
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
servers={serverOptions}
|
|
defaultServerId={sheetServerId}
|
|
busy={busy}
|
|
onSubmit={handleCreate}
|
|
/>
|
|
<WgImportSheet
|
|
open={importOpen}
|
|
onOpenChange={setImportOpen}
|
|
servers={serverOptions}
|
|
defaultServerId={sheetServerId}
|
|
busy={busy}
|
|
onImport={handleImport}
|
|
/>
|
|
<WgPeerSheet
|
|
open={!!peerIface}
|
|
iface={peerIface}
|
|
busy={busy}
|
|
onOpenChange={(v) => { if (!v) setPeerIface(null) }}
|
|
onSubmit={handleAddPeer}
|
|
/>
|
|
<WgExportSheet
|
|
open={!!exportIface}
|
|
iface={exportIface}
|
|
initialTab={exportInitialTab}
|
|
peerId={exportPeerId}
|
|
onClose={() => {
|
|
setExportIface(null)
|
|
setExportPeerId(null)
|
|
setExportInitialTab("rsc")
|
|
setLiveExport(null)
|
|
}}
|
|
liveContent={liveExport}
|
|
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>
|
|
</>
|
|
)
|
|
}
|