Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m20s
Docker images / frontend-image (push) Successful in 2m51s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
- Added configuration history management for Firewall, GRE, and WireGuard pages, enabling users to view and restore previous configurations. - Introduced new components for displaying configuration history and integrated them into the respective pages. - Enhanced API routes to support fetching and restoring configuration revisions, ensuring data consistency across the application. - Updated state management to handle loading and restoring states effectively, improving user experience during data operations. - Enhanced tests to cover new functionalities and ensure reliability. Co-authored-by: Cursor <[email protected]>
898 lines
31 KiB
TypeScript
898 lines
31 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 { ConfigHistorySheet } from "@/components/config-history-sheet"
|
|
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
|
import {
|
|
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
|
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
|
Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
|
|
} 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 [historyOpen, setHistoryOpen] = useState(false)
|
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
|
const [historyLoading, setHistoryLoading] = useState(false)
|
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
|
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 historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
|
|
|
|
const loadRevisions = useCallback(async () => {
|
|
if (!isLive || !historyServerId) return
|
|
setHistoryLoading(true)
|
|
try {
|
|
const res = await requestJson<{ revisions: ConfigRevisionDto[] }>(
|
|
backendUrl,
|
|
`/api/wireguard/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
|
)
|
|
setRevisions(res.revisions)
|
|
} catch (err) {
|
|
toast.error("Не удалось загрузить историю", { description: String(err) })
|
|
setRevisions([])
|
|
} finally {
|
|
setHistoryLoading(false)
|
|
}
|
|
}, [isLive, historyServerId, backendUrl])
|
|
|
|
const restoreRevision = useCallback(async (id: string) => {
|
|
if (!isLive || !historyServerId) return
|
|
setHistoryRestoring(true)
|
|
try {
|
|
await requestJson(
|
|
backendUrl,
|
|
`/api/wireguard/revisions/${encodeURIComponent(id)}/restore`,
|
|
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
|
|
)
|
|
toast.success("Версия применена на роутер")
|
|
await loadLive()
|
|
await loadRevisions()
|
|
} catch (err) {
|
|
toast.error("Не удалось откатить", { description: String(err) })
|
|
} finally {
|
|
setHistoryRestoring(false)
|
|
}
|
|
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
|
|
|
|
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"
|
|
disabled={loading || !historyServerId}
|
|
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
|
|
onClick={() => {
|
|
setHistoryOpen(true)
|
|
void loadRevisions()
|
|
}}
|
|
>
|
|
<HistoryIcon className="size-4" />
|
|
История
|
|
</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>
|
|
|
|
<ConfigHistorySheet
|
|
open={historyOpen}
|
|
onOpenChange={setHistoryOpen}
|
|
title="История WireGuard"
|
|
itemLabel="интерфейсов"
|
|
revisions={revisions}
|
|
loading={historyLoading}
|
|
restoring={historyRestoring}
|
|
onRestore={restoreRevision}
|
|
/>
|
|
</>
|
|
)
|
|
}
|