995 lines
52 KiB
TypeScript
995 lines
52 KiB
TypeScript
"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<GreStatus, { label: string; dot: string }> = {
|
||
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<typeof vxlanServersSchema>[number]
|
||
type InventoryServer = Server & { monitoringUnknown?: boolean }
|
||
|
||
function makeApiFetch(backendUrl: string) {
|
||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
return requestJson<T>(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<InventoryServer[]>([])
|
||
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
|
||
const [dataLoading, setDataLoading] = useState(true)
|
||
const [dataError, setDataError] = useState<string | null>(null)
|
||
const [failures, setFailures] = useState<z.infer<typeof greResponseSchema>["failures"]>([])
|
||
const failedServerIds = useMemo(() => failures.map(f => f.serverId), [failures])
|
||
const [hasLoaded, setHasLoaded] = useState(false)
|
||
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
|
||
const [responseReceipt, setResponseReceipt] = useState<string | null>(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<PageTab>("tunnels")
|
||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||
const [search, setSearch] = useState("")
|
||
|
||
const [tunnelOpen, setTunnelOpen] = useState(false)
|
||
const [tunnelMode, setTunnelMode] = useState<"create" | "edit">("create")
|
||
const [editingTunnel, setEditingTunnel] = useState<GreTunnel | null>(null)
|
||
const [pendingDelete, setPendingDelete] = useState<GreTunnel | null>(null)
|
||
const formLock = useRef(false)
|
||
const [formError, setFormError] = useState<string | null>(null)
|
||
const toggleLock = useRef(false)
|
||
const [toggleStatus, setToggleStatus] = useState<string | null>(null)
|
||
const [toggleError, setToggleError] = useState<string | null>(null)
|
||
const deleteLock = useRef(false)
|
||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||
const [mutateBusy, setMutateBusy] = useState(false)
|
||
const [liveStale, setLiveStale] = useState(false)
|
||
const [historyOpen, setHistoryOpen] = useState(false)
|
||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||
const [historyLoading, setHistoryLoading] = useState(false)
|
||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||
const historyEpoch = useRef(0)
|
||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||
const [exportContext, setExportContext] = useState<{ servers: Record<string, Server>; at: string | null; stale: boolean; mock: boolean } | null>(null)
|
||
|
||
const [tForm, setTForm] = useState(defaultTunnelForm)
|
||
|
||
const setT = <K extends keyof typeof defaultTunnelForm>(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<unknown>("/api/servers"), apiFetch<unknown>("/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<ServerTileItem[]>(() => (
|
||
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<unknown>(
|
||
`/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<unknown>("/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 (
|
||
<>
|
||
<ServerRailLayout
|
||
items={greRailItems}
|
||
selectedId={selectedServerId}
|
||
preserveSelection
|
||
onSelect={onSelectServer}
|
||
showAll
|
||
allCount={displayServers.length}
|
||
loading={isLive && dataLoading && displayServers.length === 0}
|
||
banner={isLive && !can("mm:network:write") ? <p role="status" className="border-b px-4 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение конфигурации недоступно.</p> : undefined}
|
||
header={
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||
actions={
|
||
<>
|
||
<ServerRailMobileButton />
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => { void loadLive() }}
|
||
disabled={!isLive || dataLoading || mutateBusy || historyRestoring}
|
||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||
>
|
||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setHistoryOpen(true)
|
||
void loadRevisions()
|
||
}}
|
||
disabled={!isLive || !historyServerId || dataLoading || mutateBusy || historyRestoring}
|
||
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
|
||
>
|
||
<HistoryIcon className="size-4" />
|
||
История
|
||
</Button>
|
||
<Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy || (isLive && !canCreate)}>
|
||
<PlusIcon className="size-4" />Добавить туннель
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-5">
|
||
{selectedTunnelId !== null && (
|
||
<Alert variant={selectedTunnelError || ((!isLive || !dataLoading) && !selectedTunnel) ? "warning" : "info"} role="status" aria-label="GRE-туннель по ссылке">
|
||
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedTunnel ? "Выбран GRE-туннель: " + selectedTunnel.name : "GRE-туннель по ссылке"}</AlertTitle>
|
||
<AlertDescription>
|
||
<p className="wrap-anywhere">{isLive && dataLoading ? "Загрузка туннеля…" : selectedTunnelError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · " + selectedTunnel.remoteAddress + ". Список ограничен выбранным туннелем; сводка относится к этому срезу." : "Туннель не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}</p>
|
||
<div className="flex flex-wrap gap-4">
|
||
<Link href={allTunnelsHref} className="underline underline-offset-2">Все туннели сервера</Link>
|
||
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
||
</div>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{isLive && (dataError || failures.length > 0) && (
|
||
<Alert variant="warning" role="alert">
|
||
<AlertTitle>{dataError ? "Не удалось загрузить GRE" : "Не удалось опросить часть роутеров"}</AlertTitle>
|
||
<AlertDescription>
|
||
<p>{hasLoaded ? "Показаны последние полученные данные; актуальность отказавших устройств не подтверждена." : "Данные пока неизвестны. Пустой список не означает отсутствие туннелей."}</p>
|
||
<p className="wrap-anywhere">{dataError ?? failures.map(f => (f.serverName ?? f.serverId) + ": " + f.error).join("; ")}</p>
|
||
<Button variant="outline" size="sm" disabled={dataLoading || mutateBusy || historyRestoring} onClick={() => { void loadLive() }}>Повторить загрузку GRE</Button>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
<KpiStatGrid
|
||
aria-label="Сводка GRE"
|
||
items={[
|
||
{
|
||
id: "tunnels",
|
||
label: "Туннелей",
|
||
value: incomplete ? "—" : scopedTunnels.length,
|
||
hint: incomplete ? "неизвестно" : undefined,
|
||
icon: <NetworkIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "up",
|
||
label: "Активно",
|
||
value: incomplete ? "—" : upCount,
|
||
hint: incomplete ? "неизвестно" : undefined,
|
||
icon: <ShieldCheckIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "ipsec",
|
||
label: "С IPsec",
|
||
value: incomplete ? "—" : ipsecCount,
|
||
hint: incomplete ? "неизвестно" : undefined,
|
||
icon: <LockIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
{
|
||
id: "pools",
|
||
label: "IP-пулов",
|
||
value: isLive ? "—" : displayPools.length,
|
||
hint: isLive ? "нет данных API" : "моковые данные",
|
||
icon: <DatabaseIcon className="size-4" />,
|
||
iconClassName: "text-primary",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<div role="group" aria-label="Свежесть GRE" className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-muted-foreground">
|
||
<span data-source-time="read">{isLive ? selectedServerId === ALL_SERVERS_ID ? "Ответ каталога: " : "Опрос устройства: " : "Моковые данные · "}{isLive ? scopeReceipt ? <time dateTime={scopeReceipt} title="Время получения ответа браузером; не время измерения RouterOS">{formatReceipt(scopeReceipt)}</time> : "неизвестно" : "не получены с RouterOS"}</span>
|
||
{isLive && writeReceipt && (selectedServerId === ALL_SERVERS_ID || writeReceipt.serverId === selectedServerId) && <span data-source-time="write">API подтвердил изменение · {serverById[writeReceipt.serverId]?.name ?? writeReceipt.serverId}: <time dateTime={writeReceipt.at}>{formatReceipt(writeReceipt.at)}</time></span>}
|
||
</div>
|
||
|
||
{toggleStatus && <p role="status" className="text-sm text-muted-foreground">{toggleStatus}</p>}
|
||
{toggleError && <p role="alert" className="text-sm text-destructive">{toggleError}</p>}
|
||
|
||
{/* Page tabs */}
|
||
<div role="group" aria-label="Раздел GRE" className="flex items-center gap-1 border-b">
|
||
{(["tunnels", "pools"] as PageTab[]).map((tab) => (
|
||
<button key={tab} aria-pressed={pageTab === tab} onClick={() => 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-пулы"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Tunnels ── */}
|
||
{pageTab === "tunnels" && (
|
||
<DataPageCard>
|
||
<DataPageToolbar
|
||
segmented={{
|
||
value: tabFilter,
|
||
onChange: setTabFilter,
|
||
options: tunnelTabs.map((t) => ({
|
||
value: t.value,
|
||
label: t.label,
|
||
count: incomplete ? undefined : t.count,
|
||
})),
|
||
}}
|
||
search={search}
|
||
onSearchChange={setSearch}
|
||
searchPlaceholder="Поиск по имени, IP…"
|
||
countLabel={incomplete ? `${filtered.length} сохранённых строк · сводка неизвестна` : `${filtered.length} туннелей`}
|
||
/>
|
||
|
||
<GreTunnelsDataGrid
|
||
tunnels={filtered}
|
||
servers={displayServers}
|
||
pools={displayPools}
|
||
onCodePreview={openCodePreview}
|
||
onEdit={openEditTunnel}
|
||
onToggle={(t) => { void toggleTunnel(t) }}
|
||
onDelete={setPendingDelete}
|
||
mutationsLocked={mutationsLocked}
|
||
canManage={canManage}
|
||
unknownServerIds={unknownServerIds}
|
||
emptyTitle={isLive && !hasLoaded ? dataLoading ? "Загрузка GRE…" : "Данные GRE неизвестны" : incomplete ? "Нет подтверждённых строк" : undefined}
|
||
emptyDescription={incomplete ? "Повторите опрос; отсутствие строк не подтверждено" : undefined}
|
||
/>
|
||
</DataPageCard>
|
||
)}
|
||
|
||
{/* ── IP Pools ── */}
|
||
{pageTab === "pools" && (
|
||
<DataPageCard>
|
||
{isLive ? (
|
||
<div role="status" className="p-5 text-sm text-muted-foreground">API GRE возвращает интерфейсы и назначенные IP. Каталог пулов, CIDR и свободная ёмкость не предоставляются; управление пулами здесь недоступно.</div>
|
||
) : <>
|
||
<DataPageToolbarFrame className="justify-between"><span className="text-sm text-muted-foreground">Моковый каталог · только просмотр</span></DataPageToolbarFrame>
|
||
<GrePoolsDataGrid pools={displayPools} />
|
||
|
||
<div className="border-t px-5 py-4">
|
||
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
|
||
<div className="flex flex-col gap-3">
|
||
{displayPools.map((pool) => {
|
||
const poolTunnels = displayTunnels.filter((t) => t.poolId === pool.id)
|
||
return (
|
||
<div key={pool.id}>
|
||
<p className="text-xs font-mono font-medium mb-1.5">{pool.name}</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{poolTunnels.map((t, tunnelIndex) => (
|
||
<div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
|
||
<span className={`size-1.5 rounded-full ${greStatusMeta(t.status).dot}`} />
|
||
<span className="font-mono font-medium">{t.name}</span>
|
||
<span className="text-muted-foreground">{t.localInnerIp} ↔ {t.remoteInnerIp}</span>
|
||
{t.ipsec && <LockIcon className="size-3 text-emerald-400" />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</>}
|
||
</DataPageCard>
|
||
)}
|
||
|
||
{/* RouterOS reference */}
|
||
<OpsPanel title="Справка GRE" contentClassName="px-5 py-4">
|
||
<p className="text-xs text-muted-foreground">Экспорт содержит интерфейс и локальный IP выбранного туннеля. Удалённую сторону и маршруты настройте отдельно. <a href="https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE" target="_blank" rel="noreferrer" className="underline underline-offset-2">Параметры GRE в документации MikroTik</a></p>
|
||
</OpsPanel>
|
||
</div>
|
||
</ServerRailLayout>
|
||
|
||
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
||
<CodeExportSheet
|
||
className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
|
||
open={!!codePreviewTunnel}
|
||
onClose={() => 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 ? (
|
||
<div className="flex flex-wrap gap-3 text-xs shrink-0">
|
||
<span className="flex items-center gap-1.5">
|
||
<span className={`size-1.5 rounded-full ${exportContext?.stale ? "bg-muted-foreground" : greStatusMeta(codePreviewTunnel.status).dot}`} />
|
||
{exportContext?.stale ? "Неизвестно" : greStatusMeta(codePreviewTunnel.status).label}
|
||
</span>
|
||
<span className="text-muted-foreground">·</span>
|
||
<span>{exportContext?.servers[codePreviewTunnel.serverId]?.name}</span>
|
||
<span className="text-muted-foreground">·</span>
|
||
<span className="font-mono">
|
||
{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
|
||
{" → "}
|
||
{codePreviewTunnel.remoteAddress}
|
||
</span>
|
||
{codePreviewTunnel.ipsec ? (
|
||
<>
|
||
<span className="text-muted-foreground">·</span>
|
||
<span className="flex items-center gap-1 text-success">
|
||
<LockIcon className="size-3" />
|
||
IPsec · PSK
|
||
</span>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : null
|
||
}
|
||
/>
|
||
|
||
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
||
<Sheet open={tunnelOpen} onOpenChange={v => { if (!formLock.current) setTunnelOpen(v) }}>
|
||
<SheetContent showCloseButton={!mutateBusy} side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg flex flex-col gap-0 p-0">
|
||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||
<SheetTitle>{tunnelMode === "edit" ? "Редактировать GRE-туннель" : "Новый GRE-туннель"}</SheetTitle>
|
||
<SheetDescription>RouterOS 7.20+ · /interface gre {tunnelMode === "edit" ? "set" : "add"}</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||
<fieldset disabled={mutateBusy} className="flex flex-col gap-5">
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Основные</SectionTitle>
|
||
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
|
||
<Input className="font-mono" placeholder="gre-msk-spb" aria-label="Имя интерфейса" value={tForm.name} disabled={tunnelMode === "edit"} onChange={(e) => setT("name", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Сервер (MikroTik)" required>
|
||
<select aria-label="Сервер (MikroTik)" value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)} disabled={tunnelMode === "edit"}
|
||
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">
|
||
<option value="" disabled>Выбрать сервер…</option>
|
||
{displayServers.map((s) => <option key={s.id} value={s.id} disabled={isLive && !canManage(s.id)}>{s.name}{s.site ? ` (${s.site})` : ""}{isLive && !canManage(s.id) ? " — недоступен для изменений" : ""}</option>)}
|
||
</select>
|
||
</FormField>
|
||
<FormField label="Комментарий">
|
||
<Input placeholder="Описание туннеля" aria-label="Комментарий" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
|
||
</FormField>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">Включён</span>
|
||
<FormToggle ariaLabel="Туннель включён" checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Эндпоинты</SectionTitle>
|
||
<FormField label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
|
||
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
|
||
<Input className="font-mono" placeholder="203.0.113.1" aria-label="Удалённый адрес" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Внутренний IP</SectionTitle>
|
||
{!isLive && <p className="text-xs text-muted-foreground">Моковые пулы доступны только для просмотра.</p>}
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<FormField label="Локальный IP" hint="/ip address на этом конце">
|
||
<Input className="font-mono" aria-label="Локальный IP" placeholder="IPv4/IPv6 с префиксом" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Удалённый IP" hint="/ip address на другом конце">
|
||
<Input className="font-mono" aria-label="Удалённый IP" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>IPsec</SectionTitle>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">Включить IPsec</p>
|
||
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
|
||
</div>
|
||
<FormToggle ariaLabel="Включить IPsec" checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
|
||
</div>
|
||
|
||
{tForm.ipsecEnabled && (
|
||
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
|
||
<FormField label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
|
||
<div className="relative">
|
||
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
|
||
aria-label="Пароль (PSK)" placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
|
||
<button type="button" aria-label={tForm.ipsecShowSecret ? "Скрыть PSK" : "Показать PSK"} onClick={() => setT("ipsecShowSecret", !tForm.ipsecShowSecret)}
|
||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||
</button>
|
||
</div>
|
||
</FormField>
|
||
<p className="text-xs text-muted-foreground">Применяется только PSK через ipsec-secret. Алгоритмы IKE, proposal и PFS задаются настройками RouterOS; эта форма их не изменяет.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<button type="button" onClick={() => 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 ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||
Дополнительно
|
||
<div className="flex-1 h-px bg-border" />
|
||
</button>
|
||
{tForm.showAdvanced && (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<FormField label="MTU" hint="По умолч. 1476">
|
||
<Input aria-label="MTU" type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
|
||
</FormField>
|
||
<FormField label="Keepalive, с" hint="Целое число, минимум 1 с">
|
||
<Input aria-label="Keepalive, с" type="number" min={1} max={4294967295} step={1} className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
||
</FormField>
|
||
<FormField label="Попытки">
|
||
<Input aria-label="Попытки" type="number" min={1} max={4294967295} step={1} className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
||
</FormField>
|
||
</div>
|
||
<FormField label="DSCP">
|
||
<select aria-label="DSCP" value={tForm.dscp} onChange={(e) => 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">
|
||
<option value="inherit">inherit</option>
|
||
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
||
</select>
|
||
</FormField>
|
||
{[
|
||
{ 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 }) => (
|
||
<div key={key} className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">{label}</p>
|
||
<p className="text-xs text-muted-foreground">{desc}</p>
|
||
</div>
|
||
<FormToggle ariaLabel={label} checked={key === "allowFastPath" && tForm.ipsecEnabled ? false : tForm[key] as boolean} disabled={key === "allowFastPath" && tForm.ipsecEnabled} onChange={(v) => setT(key, v)} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</fieldset>
|
||
</div>
|
||
|
||
{mutateBusy && <p role="status" className="px-6 text-sm text-muted-foreground">Ожидание результата сохранения…</p>}
|
||
{formError && <p role="alert" className="px-6 text-sm text-destructive">{formError}</p>}
|
||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={mutateBusy} />}>Отмена</SheetClose>
|
||
<Button className="flex-1" onClick={() => void submitTunnel()} disabled={mutateBusy || (isLive && !canManage(tForm.serverId))}>
|
||
{tunnelMode === "edit" ? "Сохранить" : "Создать туннель"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
|
||
<ConfigHistorySheet
|
||
open={historyOpen}
|
||
onOpenChange={v => { 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}
|
||
/>
|
||
|
||
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) } }}>
|
||
<AlertDialogContent size="default">
|
||
<AlertDialogHeader>
|
||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||
<Trash2Icon />
|
||
</AlertDialogMedia>
|
||
<AlertDialogTitle>Удалить GRE-туннель?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
{pendingDelete
|
||
? `${pendingDelete.name} на сервере ${serverById[pendingDelete.serverId]?.name ?? pendingDelete.serverId}. Будут удалены интерфейс и связанный /ip/address.`
|
||
: null}
|
||
</AlertDialogDescription>
|
||
{mutateBusy && <p role="status" className="text-sm text-muted-foreground">Ожидание результата удаления…</p>}
|
||
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel disabled={mutateBusy}>Отмена</AlertDialogCancel>
|
||
<AlertDialogAction variant="destructive" disabled={!pendingDelete || !canManage(pendingDelete.serverId)} onClick={(e) => { e.preventDefault(); void confirmDeleteTunnel() }}>
|
||
Удалить
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</>
|
||
)
|
||
}
|
||
|
||
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 <GrePageContent key={JSON.stringify([serverId, tunnelId, mode, backendUrl])} selectedServerId={serverId} selectedTunnelId={tunnelId} allTunnelsHref={allTunnelsHref} onSelectServer={id => {
|
||
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 <Suspense fallback={<div role="status">Загрузка GRE…</div>}><GreRoute /></Suspense>
|
||
}
|