"use client" import { can } from "@/lib/auth" import { Suspense, useCallback, useEffect, useRef, useMemo, useState } from "react" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" import { PageHeader } from "@/components/page-header" import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar, DataPageToolbarFrame } from "@/components/data-page-toolbar" import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-grid" import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid" import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data" import type { GreTunnel, GreStatus, Server } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { generateGreRsc, greExportWarnings } from "@/lib/gre-export" import { greResponseSchema } from "@/lib/gre-response" import { vxlanServersSchema } from "@/lib/vxlan-response" import type { z } from "zod" import { requestJson } from "@/shared/api/http-client" import { cn } from "@/lib/utils" import { toast } from "sonner" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { ConfigHistorySheet } from "@/components/config-history-sheet" import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, SheetClose, } from "@/components/ui/sheet" import { PlusIcon, RefreshCwIcon, LockIcon, ShieldCheckIcon, NetworkIcon, EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon, Trash2Icon, DatabaseIcon, HistoryIcon, } from "lucide-react" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" // ─── label maps ───────────────────────────────────────────────────────────── const STATUS_MAP: Record = { up: { label: "Up", dot: "bg-emerald-500" }, degraded: { label: "Degraded", dot: "bg-amber-500" }, down: { label: "Down", dot: "bg-red-500" }, } function greStatusMeta(status: GreStatus | undefined) { return STATUS_MAP[status ?? "degraded"] ?? STATUS_MAP.degraded } // ─── small ui helpers ──────────────────────────────────────────────────────── // ─── Live API (как на /filters) ───────────────────────────────────────────── type BackendServer = z.infer[number] type InventoryServer = Server & { monitoringUnknown?: boolean } function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) } } function mapBackendToServer(s: BackendServer): InventoryServer { return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: s.site ?? "", country: s.country || "UN", asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled, status: s.status ?? "offline", monitoringUnknown: s.status == null, latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0, } } // ─── default form states ───────────────────────────────────────────────────── const defaultTunnelForm = { name: "", serverId: "", localAddress: "", remoteAddress: "", poolId: "", localInnerIp: "", remoteInnerIp: "", comment: "", enabled: true, ipsecEnabled: false, ipsecSecret: "", ipsecShowSecret: false, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10, dscp: "inherit", clampTcpMss: true, allowFastPath: true, showAdvanced: false, } type TabFilter = "all" | "up" | "ipsec" | "plain" type PageTab = "tunnels" | "pools" // ════════════════════════════════════════════════════════════════════════════ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, onSelectServer }: { selectedServerId: string selectedTunnelId: string | null allTunnelsHref: string onSelectServer: (id: string) => void }) { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const [liveServers, setLiveServers] = useState([]) const [liveTunnels, setLiveTunnels] = useState([]) const [dataLoading, setDataLoading] = useState(true) const [dataError, setDataError] = useState(null) const [failures, setFailures] = useState["failures"]>([]) const failedServerIds = useMemo(() => failures.map(f => f.serverId), [failures]) const [hasLoaded, setHasLoaded] = useState(false) const [readReceipt, setReadReceipt] = useState>({}) const [responseReceipt, setResponseReceipt] = useState(null) const [writeReceipt, setWriteReceipt] = useState<{ serverId: string; at: string } | null>(null) const loadEpoch = useRef(0) const mounted = useRef(true) const operationLock = useRef(false) useEffect(() => { mounted.current = true return () => { mounted.current = false; loadEpoch.current += 1 } }, []) const [pageTab, setPageTab] = useState("tunnels") const [tabFilter, setTabFilter] = useState("all") const [search, setSearch] = useState("") const [tunnelOpen, setTunnelOpen] = useState(false) const [tunnelMode, setTunnelMode] = useState<"create" | "edit">("create") const [editingTunnel, setEditingTunnel] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const formLock = useRef(false) const [formError, setFormError] = useState(null) const toggleLock = useRef(false) const [toggleStatus, setToggleStatus] = useState(null) const [toggleError, setToggleError] = useState(null) const deleteLock = useRef(false) const [deleteError, setDeleteError] = useState(null) const [mutateBusy, setMutateBusy] = useState(false) const [liveStale, setLiveStale] = useState(false) const [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) const [historyLoading, setHistoryLoading] = useState(false) const [historyError, setHistoryError] = useState(null) const historyEpoch = useRef(0) const [historyRestoring, setHistoryRestoring] = useState(false) const [codePreviewTunnel, setCodePreviewTunnel] = useState(null) const [exportContext, setExportContext] = useState<{ servers: Record; at: string | null; stale: boolean; mock: boolean } | null>(null) const [tForm, setTForm] = useState(defaultTunnelForm) const setT = (k: K, v: (typeof defaultTunnelForm)[K]) => setTForm((f) => ({ ...f, [k]: v })) const loadLive = useCallback(async (afterOperation = false) => { if (!isLive || !mounted.current || (operationLock.current && !afterOperation)) return const epoch = ++loadEpoch.current setDataLoading(true) try { const [serversPayload, tunnelsPayload] = await Promise.all([ apiFetch("/api/servers"), apiFetch("/api/gre/tunnels"), ]) if (epoch !== loadEpoch.current) return const inventory = vxlanServersSchema.safeParse(serversPayload) const response = greResponseSchema.safeParse(tunnelsPayload) if (!inventory.success || !response.success) throw new Error("Некорректный ответ загрузки GRE") const failedIds = new Set(response.data.failures.map(f => f.serverId)) const enabledIds = new Set(inventory.data.filter(s => s.enabled).map(s => String(s.id))) setLiveServers(inventory.data.map(mapBackendToServer)) setLiveTunnels(previous => [...response.data.tunnels, ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))]) setFailures(response.data.failures) setHasLoaded(true) const at = new Date().toISOString() setResponseReceipt(at) setReadReceipt(previous => Object.fromEntries([...enabledIds].map(id => [id, failedIds.has(id) ? previous[id] : at]).filter((entry): entry is [string, string] => !!entry[1]))) setDataError(null) setLiveStale(false) } catch (e) { if (epoch !== loadEpoch.current) return setDataError(e instanceof Error ? e.message : "Ошибка загрузки") setLiveStale(true) } finally { if (epoch === loadEpoch.current) setDataLoading(false) } }, [isLive, apiFetch]) useEffect(() => { if (!isLive) { queueMicrotask(() => { setLiveServers([]) setLiveTunnels([]) setDataError(null) setLiveStale(false) }) return } queueMicrotask(() => { void loadLive() }) }, [isLive, loadLive]) const displayTunnels = isLive ? liveTunnels : mockGreTunnels const displayServers = isLive ? liveServers : mockServers const displayPools = useMemo( () => (isLive ? [] : mockGrePools), [isLive], ) const greRailItems = useMemo(() => ( displayServers.map((s) => ({ id: s.id, name: s.name, host: s.host, site: s.site, country: s.country, status: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status, type: s.type, enabled: s.enabled, meta: `${displayTunnels.filter((t) => t.serverId === s.id).length}${isLive && (s as InventoryServer).monitoringUnknown ? " · Статус неизвестен" : ""}`, })) ), [displayServers, displayTunnels, isLive]) const scopedTunnels = useMemo(() => { if (selectedTunnelId !== null) return displayTunnels.filter(t => t.serverId === selectedServerId && t.id === selectedTunnelId) if (selectedServerId === ALL_SERVERS_ID) return displayTunnels return displayTunnels.filter((t) => t.serverId === selectedServerId) }, [displayTunnels, selectedServerId, selectedTunnelId]) const selectedTunnel = selectedTunnelId === null ? undefined : scopedTunnels[0] const selectedTunnelError = isLive && (dataError !== null || failedServerIds.includes(selectedServerId)) const serverById = useMemo( () => Object.fromEntries(displayServers.map((s) => [s.id, s])), [displayServers], ) const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId const mutationsLocked = !isLive || !can("mm:network:write") || mutateBusy || dataLoading || !hasLoaded || liveStale || historyRestoring function canManage(serverId: string) { return !mutationsLocked && !failedServerIds.includes(serverId) && liveServers.some(s => s.id === serverId && s.enabled) } function beginOperation(serverId: string) { if (!mounted.current || operationLock.current || !canManage(serverId)) return false operationLock.current = true loadEpoch.current += 1 setMutateBusy(true) return true } function endOperation() { operationLock.current = false if (mounted.current) setMutateBusy(false) } const canCreate = selectedServerId === ALL_SERVERS_ID ? liveServers.some(s => canManage(s.id)) : canManage(selectedServerId) useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive]) const loadRevisions = useCallback(async () => { if (!isLive || !historyServerId) return const epoch = ++historyEpoch.current setHistoryLoading(true) setHistoryError(null) try { const res = await apiFetch( `/api/gre/revisions?serverId=${encodeURIComponent(historyServerId)}`, ) if (epoch !== historyEpoch.current) return setRevisions(parseConfigRevisions(res, historyServerId, "gre")) } catch (error) { if (epoch !== historyEpoch.current) return setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю") } finally { if (epoch === historyEpoch.current) setHistoryLoading(false) } }, [isLive, historyServerId, apiFetch]) const restoreRevision = async (id: string) => { if (!can("mm:network:write")) throw new Error("Нет прав на изменение конфигурации") if (!historyServerId || !beginOperation(historyServerId)) throw new Error("Устройство не готово к восстановлению GRE") setHistoryRestoring(true) try { const res = await apiFetch<{ ok: boolean }>( `/api/gre/revisions/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({ serverId: historyServerId }) }, ) if (!mounted.current) return if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление") setWriteReceipt({ serverId: historyServerId, at: new Date().toISOString() }) toast.success("Версия применена на роутер") await loadLive(true) if (mounted.current) await loadRevisions() } catch (err) { if (mounted.current) throw err } finally { if (mounted.current) setHistoryRestoring(false) endOperation() } } function tunnelWriteBody(form: typeof defaultTunnelForm) { return { serverId: form.serverId, name: form.name.trim(), localAddress: form.localAddress.trim() || undefined, remoteAddress: form.remoteAddress.trim(), localInnerIp: form.localInnerIp.trim(), remoteInnerIp: form.remoteInnerIp.trim(), comment: form.comment || undefined, enabled: form.enabled, mtu: form.mtu, keepaliveInterval: form.keepaliveInterval, keepaliveRetries: form.keepaliveRetries, dscp: form.dscp, clampTcpMss: form.clampTcpMss, allowFastPath: form.ipsecEnabled ? false : form.allowFastPath, ipsecSecret: form.ipsecEnabled ? form.ipsecSecret : "", } } async function submitTunnel() { if (formLock.current || mutationsLocked) return setFormError(null) if (!isLive) { toast.info("Создание на роутер доступно только в live-режиме") return } if (liveStale) { toast.error("Роутер недоступен — изменения заблокированы") return } if (!tForm.name.trim() || !tForm.serverId || !tForm.remoteAddress.trim()) { setFormError("Заполните имя, сервер и удалённый адрес") return } if (tForm.ipsecEnabled && tForm.ipsecSecret.trim().length < 8) { setFormError("Для IPsec нужен PSK не короче 8 символов") return } if (![tForm.keepaliveInterval, tForm.keepaliveRetries].every(value => Number.isInteger(value) && value >= 1 && value <= 4294967295)) { setFormError("Keepalive и число попыток: целые значения от 1 до 4294967295. Ноль не поддерживается этим API.") return } if (!beginOperation(tForm.serverId)) { setFormError("Устройство не готово к изменению GRE; повторите опрос"); return } formLock.current = true try { if (tunnelMode === "edit" && editingTunnel) { const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", { method: "PATCH", body: JSON.stringify({ ...tunnelWriteBody(tForm), rosId: editingTunnel.id, name: editingTunnel.name, }), }) if (!mounted.current) return if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение туннеля") setWriteReceipt({ serverId: tForm.serverId, at: new Date().toISOString() }) toast.success(`Туннель ${tForm.name} обновлён`) } else { const result = await apiFetch("/api/gre/tunnels", { method: "POST", body: JSON.stringify(tunnelWriteBody(tForm)), }) if (!mounted.current) return const confirmed = typeof result === "object" && result !== null && "name" in result && result.name === tForm.name.trim() && ( ("ok" in result && result.ok === true) || ("id" in result && typeof result.id === "string" && result.id.length > 0 && "serverId" in result && String(result.serverId) === tForm.serverId && "remoteAddress" in result && result.remoteAddress === tForm.remoteAddress.trim() && "enabled" in result && result.enabled === tForm.enabled) ) if (!confirmed) throw new Error("Сервер не подтвердил создание туннеля") setWriteReceipt({ serverId: tForm.serverId, at: new Date().toISOString() }) toast.success(`Туннель ${tForm.name} создан`) } setTunnelOpen(false) setEditingTunnel(null) await loadLive(true) } catch (err) { if (mounted.current) setFormError(err instanceof Error ? err.message : "Не удалось сохранить туннель") } finally { formLock.current = false endOperation() } } async function toggleTunnel(t: GreTunnel) { if (toggleLock.current || !beginOperation(t.serverId)) return toggleLock.current = true setToggleError(null) setToggleStatus(`Ожидание изменения ${t.name}…`) try { const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", { method: "PATCH", body: JSON.stringify({ serverId: t.serverId, rosId: t.id, name: t.name, enabled: !t.enabled, remoteAddress: t.remoteAddress, }), }) if (!mounted.current) return if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение туннеля") setWriteReceipt({ serverId: t.serverId, at: new Date().toISOString() }) toast.success(t.enabled ? `Выключен ${t.name}` : `Включён ${t.name}`) await loadLive(true) } catch (err) { if (mounted.current) setToggleError(`${t.name}: ${err instanceof Error ? err.message : "Не удалось изменить туннель"}`) } finally { toggleLock.current = false if (mounted.current) setToggleStatus(null) endOperation() } } async function confirmDeleteTunnel() { const t = pendingDelete if (!t || deleteLock.current || !beginOperation(t.serverId)) return deleteLock.current = true setDeleteError(null) try { const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", { method: "DELETE", body: JSON.stringify({ serverId: t.serverId, rosId: t.id, name: t.name }), }) if (!mounted.current) return if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление туннеля") setWriteReceipt({ serverId: t.serverId, at: new Date().toISOString() }) toast.success(`Удалён ${t.name}`) setPendingDelete(null) await loadLive(true) } catch (err) { if (mounted.current) setDeleteError(err instanceof Error ? err.message : "Не удалось удалить туннель") } finally { deleteLock.current = false endOperation() } } function openCreateTunnel() { if (isLive && !canCreate) return setFormError(null) setTunnelMode("create") setEditingTunnel(null) setTForm({ ...defaultTunnelForm, serverId: selectedServerId === ALL_SERVERS_ID ? "" : selectedServerId, }) setTunnelOpen(true) } function openEditTunnel(t: GreTunnel) { if (isLive && !canManage(t.serverId)) return setFormError(null) setTunnelMode("edit") setEditingTunnel(t) setTForm({ ...defaultTunnelForm, name: t.name, serverId: t.serverId, localAddress: t.localAddress === "0.0.0.0" ? "" : t.localAddress, remoteAddress: t.remoteAddress, poolId: t.poolId === "live" ? "" : t.poolId, localInnerIp: t.localInnerIp, remoteInnerIp: t.remoteInnerIp, comment: t.comment, enabled: t.enabled, ipsecEnabled: !!t.ipsec, ipsecSecret: t.ipsec?.secret ?? "", mtu: t.mtu, keepaliveInterval: t.keepaliveInterval, keepaliveRetries: t.keepaliveRetries, dscp: String(t.dscp), clampTcpMss: t.clampTcpMss, allowFastPath: t.allowFastPath, }) setTunnelOpen(true) } const incomplete = isLive && (!hasLoaded || !!dataError || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.includes(selectedServerId))) const unknownServerIds = useMemo(() => new Set(isLive ? dataError ? displayTunnels.map(t => t.serverId) : failedServerIds : []), [isLive, dataError, displayTunnels, failedServerIds]) const filtered = useMemo(() => { return scopedTunnels.filter((t) => { if (tabFilter === "up" && (t.status !== "up" || unknownServerIds.has(t.serverId))) return false if (tabFilter === "ipsec" && !t.ipsec) return false if (tabFilter === "plain" && t.ipsec) return false if (!search) return true const q = search.toLowerCase() return ( t.name.toLowerCase().includes(q) || t.remoteAddress.includes(q) || t.localInnerIp.includes(q) || serverById[t.serverId]?.name.toLowerCase().includes(q) ) }) }, [tabFilter, search, scopedTunnels, serverById, unknownServerIds]) const upCount = scopedTunnels.filter((t) => t.status === "up").length const ipsecCount = scopedTunnels.filter((t) => t.ipsec).length const scopedUpCount = scopedTunnels.filter((t) => t.status === "up").length const scopedIpsecCount = scopedTunnels.filter((t) => t.ipsec).length const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [ { value: "all", label: "Все", count: scopedTunnels.length }, { value: "up", label: "Активные", count: scopedUpCount }, { value: "ipsec", label: "С IPsec", count: scopedIpsecCount }, { value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount }, ] function openCodePreview(t: GreTunnel) { setCodePreviewTunnel(t) setExportContext({ servers: serverById, at: readReceipt[t.serverId] ?? null, stale: isLive && (!hasLoaded || !!dataError || failedServerIds.includes(t.serverId)), mock: !isLive }) } const scopeReceipt = selectedServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[selectedServerId] const formatReceipt = (at: string) => new Date(at).toLocaleString("ru-RU") const greExportCode = useMemo( () => (codePreviewTunnel ? generateGreRsc(codePreviewTunnel, exportContext?.servers ?? {}) : ""), [codePreviewTunnel, exportContext], ) return ( <> Режим просмотра. Изменение конфигурации недоступно.

: undefined} header={ } /> } >
{selectedTunnelId !== null && ( {selectedTunnel ? "Выбран GRE-туннель: " + selectedTunnel.name : "GRE-туннель по ссылке"}

{isLive && dataLoading ? "Загрузка туннеля…" : selectedTunnelError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · " + selectedTunnel.remoteAddress + ". Список ограничен выбранным туннелем; сводка относится к этому срезу." : "Туннель не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}

Все туннели сервера К дашборду
)} {isLive && (dataError || failures.length > 0) && ( {dataError ? "Не удалось загрузить GRE" : "Не удалось опросить часть роутеров"}

{hasLoaded ? "Показаны последние полученные данные; актуальность отказавших устройств не подтверждена." : "Данные пока неизвестны. Пустой список не означает отсутствие туннелей."}

{dataError ?? failures.map(f => (f.serverName ?? f.serverId) + ": " + f.error).join("; ")}

)} , iconClassName: "text-muted-foreground", }, { id: "up", label: "Активно", value: incomplete ? "—" : upCount, hint: incomplete ? "неизвестно" : undefined, icon: , iconClassName: "text-success", }, { id: "ipsec", label: "С IPsec", value: incomplete ? "—" : ipsecCount, hint: incomplete ? "неизвестно" : undefined, icon: , iconClassName: "text-info", }, { id: "pools", label: "IP-пулов", value: isLive ? "—" : displayPools.length, hint: isLive ? "нет данных API" : "моковые данные", icon: , iconClassName: "text-primary", }, ]} />
{isLive ? selectedServerId === ALL_SERVERS_ID ? "Ответ каталога: " : "Опрос устройства: " : "Моковые данные · "}{isLive ? scopeReceipt ? : "неизвестно" : "не получены с RouterOS"} {isLive && writeReceipt && (selectedServerId === ALL_SERVERS_ID || writeReceipt.serverId === selectedServerId) && API подтвердил изменение · {serverById[writeReceipt.serverId]?.name ?? writeReceipt.serverId}: }
{toggleStatus &&

{toggleStatus}

} {toggleError &&

{toggleError}

} {/* Page tabs */}
{(["tunnels", "pools"] as PageTab[]).map((tab) => ( ))}
{/* ── Tunnels ── */} {pageTab === "tunnels" && ( ({ value: t.value, label: t.label, count: incomplete ? undefined : t.count, })), }} search={search} onSearchChange={setSearch} searchPlaceholder="Поиск по имени, IP…" countLabel={incomplete ? `${filtered.length} сохранённых строк · сводка неизвестна` : `${filtered.length} туннелей`} /> { void toggleTunnel(t) }} onDelete={setPendingDelete} mutationsLocked={mutationsLocked} canManage={canManage} unknownServerIds={unknownServerIds} emptyTitle={isLive && !hasLoaded ? dataLoading ? "Загрузка GRE…" : "Данные GRE неизвестны" : incomplete ? "Нет подтверждённых строк" : undefined} emptyDescription={incomplete ? "Повторите опрос; отсутствие строк не подтверждено" : undefined} /> )} {/* ── IP Pools ── */} {pageTab === "pools" && ( {isLive ? (
API GRE возвращает интерфейсы и назначенные IP. Каталог пулов, CIDR и свободная ёмкость не предоставляются; управление пулами здесь недоступно.
) : <> Моковый каталог · только просмотр

Назначения по пулам

{displayPools.map((pool) => { const poolTunnels = displayTunnels.filter((t) => t.poolId === pool.id) return (

{pool.name}

{poolTunnels.map((t, tunnelIndex) => (
{t.name} {t.localInnerIp} ↔ {t.remoteInnerIp} {t.ipsec && }
))}
) })}
}
)} {/* RouterOS reference */}

Экспорт содержит интерфейс и локальный IP выбранного туннеля. Удалённую сторону и маршруты настройте отдельно. Параметры GRE в документации MikroTik

{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */} setCodePreviewTunnel(null)} title={codePreviewTunnel?.name ?? "GRE"} description={(codePreviewTunnel ? greExportWarnings(codePreviewTunnel).join(" ") + " " : "") + (exportContext?.mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${exportContext?.at ? formatReceipt(exportContext.at) : "время получения неизвестно"} · ${exportContext?.stale ? "актуальность не подтверждена" : "последний успешный опрос"}`)} formats={[ { id: "rsc", label: "RouterOS", filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`, code: greExportCode, }, ]} beforeCode={ codePreviewTunnel ? (
{exportContext?.stale ? "Неизвестно" : greStatusMeta(codePreviewTunnel.status).label} · {exportContext?.servers[codePreviewTunnel.serverId]?.name} · {codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} {" → "} {codePreviewTunnel.remoteAddress} {codePreviewTunnel.ipsec ? ( <> · IPsec · PSK ) : null}
) : null } /> {/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */} { if (!formLock.current) setTunnelOpen(v) }}> {tunnelMode === "edit" ? "Редактировать GRE-туннель" : "Новый GRE-туннель"} RouterOS 7.20+ · /interface gre {tunnelMode === "edit" ? "set" : "add"}
Основные setT("name", e.target.value)} /> setT("comment", e.target.value)} />
Включён setT("enabled", v)} />
Эндпоинты setT("localAddress", e.target.value)} /> setT("remoteAddress", e.target.value)} />
Внутренний IP {!isLive &&

Моковые пулы доступны только для просмотра.

}
setT("localInnerIp", e.target.value)} /> setT("remoteInnerIp", e.target.value)} />
IPsec

Включить IPsec

RouterOS автоматически создаст peer, policy и proposal

setT("ipsecEnabled", v)} />
{tForm.ipsecEnabled && (
setT("ipsecSecret", e.target.value)} />

Применяется только PSK через ipsec-secret. Алгоритмы IKE, proposal и PFS задаются настройками RouterOS; эта форма их не изменяет.

)}
{tForm.showAdvanced && (
setT("mtu", Number(e.target.value))} /> setT("keepaliveInterval", Number(e.target.value))} /> setT("keepaliveRetries", Number(e.target.value))} />
{[ { key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" }, { key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Ускоренный путь обработки пакетов; при IPsec выключен" }, ].map(({ key, label, desc }) => (

{label}

{desc}

setT(key, v)} />
))}
)}
{mutateBusy &&

Ожидание результата сохранения…

} {formError &&

{formError}

} }>Отмена
{ if (!v) historyEpoch.current += 1; setHistoryOpen(v) }} loadError={historyError} onRetry={() => { void loadRevisions() }} title="История GRE" itemLabel="туннелей" revisions={revisions} loading={historyLoading} restoring={historyRestoring} restoreDisabled={!can("mm:network:write") || !historyServerId || !canManage(historyServerId)} onRestore={restoreRevision} /> { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) } }}> Удалить GRE-туннель? {pendingDelete ? `${pendingDelete.name} на сервере ${serverById[pendingDelete.serverId]?.name ?? pendingDelete.serverId}. Будут удалены интерфейс и связанный /ip/address.` : null} {mutateBusy &&

Ожидание результата удаления…

} {deleteError &&

{deleteError}

}
Отмена { e.preventDefault(); void confirmDeleteTunnel() }}> Удалить
) } function GreRoute() { const { mode, backendUrl } = useDataSource() const params = useSearchParams() const router = useRouter() const serverId = params.get("server") ?? ALL_SERVERS_ID const tunnelId = params.get("tunnel") const rest = new URLSearchParams(params.toString()) rest.delete("tunnel") const allTunnelsHref = "/gre" + (rest.size ? "?" + rest.toString() : "") return { const next = new URLSearchParams(rest) if (id === ALL_SERVERS_ID) next.delete("server") else next.set("server", id) router.push("/gre" + (next.size ? "?" + next.toString() : "")) }} /> } export default function GrePage() { return Загрузка GRE…}> }