"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={
{ void loadLive() }}
disabled={!isLive || dataLoading || mutateBusy || historyRestoring}
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
>
Обновить
{
setHistoryOpen(true)
void loadRevisions()
}}
disabled={!isLive || !historyServerId || dataLoading || mutateBusy || historyRestoring}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
>
История
Добавить туннель
>
}
/>
}
>
{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("; ")}
{ void loadLive() }}>Повторить загрузку GRE
)}
,
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 ? {formatReceipt(scopeReceipt)} : "неизвестно" : "не получены с RouterOS"}
{isLive && writeReceipt && (selectedServerId === ALL_SERVERS_ID || writeReceipt.serverId === selectedServerId) && API подтвердил изменение · {serverById[writeReceipt.serverId]?.name ?? writeReceipt.serverId}: {formatReceipt(writeReceipt.at)} }
{toggleStatus &&
{toggleStatus}
}
{toggleError &&
{toggleError}
}
{/* Page tabs */}
{(["tunnels", "pools"] as PageTab[]).map((tab) => (
setPageTab(tab)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${pageTab === tab ? "border-foreground text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
{tab === "tunnels" ? "Туннели" : "IP-пулы"}
))}
{/* ── 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("localAddress", e.target.value)} />
setT("remoteAddress", e.target.value)} />
IPsec
Включить IPsec
RouterOS автоматически создаст peer, policy и proposal
setT("ipsecEnabled", v)} />
{tForm.ipsecEnabled && (
)}
setT("showAdvanced", !tForm.showAdvanced)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
{tForm.showAdvanced ? : }
Дополнительно
{tForm.showAdvanced && (
setT("mtu", Number(e.target.value))} />
setT("keepaliveInterval", Number(e.target.value))} />
setT("keepaliveRetries", Number(e.target.value))} />
setT("dscp", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
inherit
{Array.from({ length: 64 }, (_, i) => {i} )}
{[
{ 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 }) => (
))}
)}
{mutateBusy && Ожидание результата сохранения…
}
{formError && {formError}
}
}>Отмена
void submitTunnel()} disabled={mutateBusy || (isLive && !canManage(tForm.serverId))}>
{tunnelMode === "edit" ? "Сохранить" : "Создать туннель"}
{ 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…}>
}