Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 2m7s
Docker images / updater-image (push) Successful in 41s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
844 lines
43 KiB
TypeScript
844 lines
43 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||
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, SegmentedControl } from "@/components/form-kit"
|
||
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
|
||
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
import { cn } from "@/lib/utils"
|
||
import { toast } from "sonner"
|
||
import { Card, CardContent } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import {
|
||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||
SheetDescription, SheetFooter, SheetClose,
|
||
} from "@/components/ui/sheet"
|
||
import {
|
||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||
} from "@/components/ui/dropdown-menu"
|
||
import { Flag } from "@/components/flag"
|
||
import {
|
||
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||
DatabaseIcon,
|
||
} from "lucide-react"
|
||
|
||
// ─── label maps ─────────────────────────────────────────────────────────────
|
||
|
||
const ENC_LABELS: Record<IpsecEncAlg, string> = { "aes-128": "AES-128", "aes-192": "AES-192", "aes-256": "AES-256" }
|
||
const AUTH_LABELS: Record<IpsecAuthAlg, string> = { sha1: "SHA-1", sha256: "SHA-256", sha512: "SHA-512" }
|
||
const DH_LABELS: Record<IpsecDhGroup, string> = {
|
||
modp1024: "DH-2 (1024)", modp2048: "DH-14 (2048)", modp4096: "DH-16 (4096)",
|
||
ecp256: "ECP-256", ecp384: "ECP-384", ecp521: "ECP-521",
|
||
}
|
||
const IKE_LABELS: Record<IkeVersion, string> = { ikev1: "IKEv1", ikev2: "IKEv2" }
|
||
|
||
const ENC_ROS: Record<IpsecEncAlg, string> = { "aes-128": "aes-128-cbc", "aes-192": "aes-192-cbc", "aes-256": "aes-256-cbc" }
|
||
const AUTH_ROS: Record<IpsecAuthAlg, string> = { sha1: "sha1", sha256: "sha256", sha512: "sha512" }
|
||
|
||
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" },
|
||
}
|
||
|
||
// ─── RouterOS code generator ─────────────────────────────────────────────────
|
||
|
||
function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>): string {
|
||
const lines: string[] = []
|
||
const srv = serverById[t.serverId]
|
||
|
||
lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`)
|
||
lines.push(`# Сгенерировано MikrotikManager · ${new Date().toLocaleDateString("ru")}`)
|
||
lines.push("")
|
||
|
||
// GRE interface
|
||
lines.push("# ── GRE-интерфейс ────────────────────────────────────────────")
|
||
const greParts = [
|
||
`/interface gre add`,
|
||
` name=${t.name}`,
|
||
` remote-address=${t.remoteAddress}`,
|
||
t.localAddress !== "0.0.0.0" ? ` local-address=${t.localAddress}` : null,
|
||
t.ipsec ? ` ipsec-secret="${t.ipsec.secret}"` : null,
|
||
` mtu=${t.mtu}`,
|
||
t.keepaliveInterval > 0 ? ` keepalive=${t.keepaliveInterval}s,${t.keepaliveRetries}` : ` keepalive=0`,
|
||
` dscp=${t.dscp}`,
|
||
` clamp-tcp-mss=${t.clampTcpMss ? "yes" : "no"}`,
|
||
` allow-fast-path=${t.allowFastPath ? "yes" : "no"}`,
|
||
t.comment ? ` comment="${t.comment}"` : null,
|
||
!t.enabled ? ` disabled=yes` : null,
|
||
].filter(Boolean) as string[]
|
||
lines.push(greParts.join(" \\\n"))
|
||
|
||
// Inner IP
|
||
lines.push("")
|
||
lines.push("# ── Внутренний IP ────────────────────────────────────────────")
|
||
lines.push(`/ip address add \\`)
|
||
lines.push(` address=${t.localInnerIp} \\`)
|
||
lines.push(` interface=${t.name}`)
|
||
|
||
// IPsec manual equivalent
|
||
if (t.ipsec) {
|
||
const ikeMode = t.ipsec.ikeVersion === "ikev2" ? "ike2" : "ike1"
|
||
const pfsGroup = t.ipsec.pfs ? t.ipsec.dhGroup : "none"
|
||
|
||
lines.push("")
|
||
lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────")
|
||
lines.push("")
|
||
lines.push(`/ip ipsec peer add \\`)
|
||
lines.push(` name=${t.name} \\`)
|
||
lines.push(` address=${t.remoteAddress} \\`)
|
||
lines.push(` exchange-mode=${ikeMode} \\`)
|
||
lines.push(` auth-method=pre-shared-key \\`)
|
||
lines.push(` secret="${t.ipsec.secret}"`)
|
||
lines.push("")
|
||
lines.push(`/ip ipsec proposal add \\`)
|
||
lines.push(` name=${t.name} \\`)
|
||
lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`)
|
||
lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`)
|
||
lines.push(` pfs-group=${pfsGroup} \\`)
|
||
lines.push(` lifetime=${t.ipsec.lifetime}`)
|
||
lines.push("")
|
||
lines.push(`/ip ipsec policy add \\`)
|
||
lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`)
|
||
lines.push(` dst-address=${t.remoteAddress}/32 \\`)
|
||
lines.push(` proposal=${t.name} \\`)
|
||
lines.push(` tunnel=yes`)
|
||
}
|
||
|
||
return lines.join("\n")
|
||
}
|
||
|
||
// ─── small ui helpers ────────────────────────────────────────────────────────
|
||
|
||
function TunnelStatus({ status }: { status: GreStatus }) {
|
||
const s = STATUS_MAP[status]
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||
<span className={`size-1.5 rounded-full ${s.dot}`} />
|
||
{s.label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function IpsecBadge({ secured }: { secured: boolean }) {
|
||
return secured ? (
|
||
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
|
||
<LockIcon className="size-3" /> IPsec
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-muted text-muted-foreground border-border">
|
||
<LockOpenIcon className="size-3" /> Открытый
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// ─── Live API (как на /filters) ─────────────────────────────────────────────
|
||
|
||
interface BackendServer {
|
||
id: number
|
||
name: string
|
||
host: string
|
||
type: "jump-host" | "exit-node" | "home-router"
|
||
site: string
|
||
country: string
|
||
asn: string
|
||
enabled: boolean
|
||
status: "online" | "offline" | null
|
||
latency: number | null
|
||
}
|
||
|
||
interface GreTunnelsApiResponse {
|
||
tunnels: GreTunnel[]
|
||
}
|
||
|
||
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): Server {
|
||
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,
|
||
enabled: s.enabled,
|
||
status: (s.status ?? "offline") as Server["status"],
|
||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||
sessions: 0,
|
||
}
|
||
}
|
||
|
||
function derivePoolsFromTunnels(tunnels: GreTunnel[]): GrePool[] {
|
||
const ids = [...new Set(tunnels.map((t) => t.poolId).filter(Boolean))]
|
||
return ids.map((id) => {
|
||
const list = tunnels.filter((t) => t.poolId === id)
|
||
const allocated = list.length
|
||
return {
|
||
id,
|
||
name: id === "live" ? "С устройств (опрос)" : id,
|
||
cidr: "—",
|
||
allocated,
|
||
total: Math.max(allocated, 1),
|
||
comment: "По poolId из данных GRE",
|
||
}
|
||
})
|
||
}
|
||
|
||
// ─── default form states ─────────────────────────────────────────────────────
|
||
|
||
const defaultTunnelForm = {
|
||
name: "", serverId: "", localAddress: "", remoteAddress: "",
|
||
poolId: "", localInnerIp: "", remoteInnerIp: "", comment: "", enabled: true,
|
||
ipsecEnabled: false, ipsecSecret: "", ipsecShowSecret: false,
|
||
ipsecIkeVersion: "ikev2" as IkeVersion,
|
||
ipsecEncAlg: "aes-256" as IpsecEncAlg,
|
||
ipsecAuthAlg: "sha256" as IpsecAuthAlg,
|
||
ipsecDhGroup: "modp2048" as IpsecDhGroup,
|
||
ipsecLifetime: "1d 00:00:00", ipsecPfs: true,
|
||
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
|
||
dscp: "inherit", clampTcpMss: true, allowFastPath: true,
|
||
showAdvanced: false,
|
||
}
|
||
|
||
const defaultPoolForm = { name: "", cidr: "", comment: "" }
|
||
|
||
type TabFilter = "all" | "up" | "ipsec" | "plain"
|
||
type PageTab = "tunnels" | "pools"
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
export default function GrePage() {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const isLive = mode === "live"
|
||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||
|
||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
|
||
const [dataLoading, setDataLoading] = useState(false)
|
||
const [dataError, setDataError] = useState<string | null>(null)
|
||
const [syncJhBusy, setSyncJhBusy] = useState(false)
|
||
|
||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||
const [search, setSearch] = useState("")
|
||
|
||
const [tunnelOpen, setTunnelOpen] = useState(false)
|
||
const [poolOpen, setPoolOpen] = useState(false)
|
||
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||
const [copied, setCopied] = useState(false)
|
||
|
||
const [tForm, setTForm] = useState(defaultTunnelForm)
|
||
const [pForm, setPForm] = useState(defaultPoolForm)
|
||
|
||
const setT = <K extends keyof typeof defaultTunnelForm>(k: K, v: (typeof defaultTunnelForm)[K]) =>
|
||
setTForm((f) => ({ ...f, [k]: v }))
|
||
|
||
const loadLive = useCallback(async () => {
|
||
if (!isLive) return
|
||
setDataLoading(true)
|
||
setDataError(null)
|
||
try {
|
||
const [backendServers, greRes] = await Promise.all([
|
||
apiFetch<BackendServer[]>("/api/servers"),
|
||
apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels"),
|
||
])
|
||
setLiveServers(backendServers.map(mapBackendToServer))
|
||
setLiveTunnels(greRes.tunnels)
|
||
} catch (e) {
|
||
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||
setLiveServers([])
|
||
setLiveTunnels([])
|
||
} finally {
|
||
setDataLoading(false)
|
||
}
|
||
}, [isLive, apiFetch])
|
||
|
||
useEffect(() => {
|
||
if (!isLive) {
|
||
queueMicrotask(() => {
|
||
setLiveServers([])
|
||
setLiveTunnels([])
|
||
setDataError(null)
|
||
})
|
||
return
|
||
}
|
||
queueMicrotask(() => {
|
||
void loadLive()
|
||
})
|
||
}, [isLive, loadLive])
|
||
|
||
const displayTunnels = isLive ? liveTunnels : mockGreTunnels
|
||
const displayServers = isLive ? liveServers : mockServers
|
||
const displayPools = useMemo(
|
||
() => (isLive ? derivePoolsFromTunnels(displayTunnels) : mockGrePools),
|
||
[isLive, displayTunnels],
|
||
)
|
||
|
||
const serverById = useMemo(
|
||
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||
[displayServers],
|
||
)
|
||
const poolById = useMemo(
|
||
() => Object.fromEntries(displayPools.map((p) => [p.id, p])),
|
||
[displayPools],
|
||
)
|
||
|
||
const syncJhToDb = useCallback(async () => {
|
||
if (!isLive || syncJhBusy) return
|
||
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
|
||
if (jh.length === 0) {
|
||
toast.info("Нет включённых Jump Host в списке серверов")
|
||
return
|
||
}
|
||
setSyncJhBusy(true)
|
||
const errors: string[] = []
|
||
try {
|
||
for (const s of jh) {
|
||
try {
|
||
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", {
|
||
method: "POST",
|
||
body: JSON.stringify({ serverId: s.id }),
|
||
})
|
||
} catch (e) {
|
||
errors.push(`${s.name}: ${e instanceof Error ? e.message : "ошибка"}`)
|
||
}
|
||
}
|
||
const fresh = await apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels")
|
||
setLiveTunnels(fresh.tunnels)
|
||
if (errors.length) {
|
||
toast.warning(`Синхронизировано JH: ${jh.length - errors.length}/${jh.length}. Ошибки: ${errors.join("; ")}`)
|
||
} else {
|
||
toast.success(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
|
||
}
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Ошибка после синхронизации")
|
||
} finally {
|
||
setSyncJhBusy(false)
|
||
}
|
||
}, [isLive, syncJhBusy, apiFetch, displayServers])
|
||
|
||
useEffect(() => {
|
||
if (dataError) toast.error(dataError)
|
||
}, [dataError])
|
||
|
||
const filtered = useMemo(() => {
|
||
return displayTunnels.filter((t) => {
|
||
if (tabFilter === "up" && t.status !== "up") 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, displayTunnels, serverById])
|
||
|
||
const upCount = displayTunnels.filter((t) => t.status === "up").length
|
||
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
|
||
|
||
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
||
{ value: "all", label: "Все", count: displayTunnels.length },
|
||
{ value: "up", label: "Активные", count: upCount },
|
||
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
|
||
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
|
||
]
|
||
|
||
function handleCopy(code: string) {
|
||
navigator.clipboard.writeText(code).then(() => {
|
||
setCopied(true)
|
||
toast.success("Команды скопированы")
|
||
setTimeout(() => setCopied(false), 2000)
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||
actions={
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => { void loadLive() }}
|
||
disabled={!isLive || dataLoading}
|
||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||
>
|
||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => { void syncJhToDb() }}
|
||
disabled={!isLive || syncJhBusy || dataLoading}
|
||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||
>
|
||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||
JH → БД
|
||
</Button>
|
||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||
<PlusIcon className="size-4" />Добавить туннель
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="flex flex-col gap-5">
|
||
|
||
{/* Legacy banner */}
|
||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
||
<ShieldCheckIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||
В RouterOS 7.x рекомендуется использовать WireGuard вместо GRE+IPsec
|
||
</p>
|
||
<p className="text-muted-foreground text-xs mt-0.5">
|
||
WireGuard проще в настройке, обеспечивает лучшую производительность и современную криптографию (ChaCha20-Poly1305).
|
||
GRE-туннели остаются поддерживаемыми, но WireGuard — предпочтительный выбор для новых развёртываний.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats */}
|
||
<div className="grid grid-cols-4 gap-4">
|
||
{[
|
||
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||
].map((s) => (
|
||
<Card key={s.label}>
|
||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||
</div>
|
||
<div className="mt-0.5">{s.icon}</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* Page tabs */}
|
||
<div className="flex items-center gap-1 border-b">
|
||
{(["tunnels", "pools"] as PageTab[]).map((tab) => (
|
||
<button key={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: t.count,
|
||
})),
|
||
}}
|
||
search={search}
|
||
onSearchChange={setSearch}
|
||
searchPlaceholder="Поиск по имени, IP…"
|
||
countLabel={`${filtered.length} туннелей`}
|
||
/>
|
||
|
||
<GreTunnelsDataGrid
|
||
tunnels={filtered}
|
||
servers={displayServers}
|
||
pools={displayPools}
|
||
onCodePreview={setCodePreviewTunnel}
|
||
/>
|
||
</DataPageCard>
|
||
)}
|
||
|
||
{/* ── IP Pools ── */}
|
||
{pageTab === "pools" && (
|
||
<DataPageCard>
|
||
<DataPageToolbarFrame className="justify-between">
|
||
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
|
||
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
||
<PlusIcon className="size-4" />
|
||
Добавить пул
|
||
</Button>
|
||
</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 ${STATUS_MAP[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 */}
|
||
<Card>
|
||
<CardContent className="px-5 py-4">
|
||
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7.20+ — параметры GRE-интерфейса</p>
|
||
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-xs font-mono">
|
||
{[
|
||
["/interface gre add", ""],
|
||
[" name=", "имя интерфейса"],
|
||
[" remote-address=", "IP удалённого конца (обязателен)"],
|
||
[" local-address=", "0.0.0.0 = авто"],
|
||
[" ipsec-secret=", "PSK → auto peer+policy+proposal"],
|
||
[" mtu=", "по умолчанию 1476"],
|
||
[" keepalive=", "10s,10 (интервал,попытки)"],
|
||
[" dscp=", "inherit | 0-63"],
|
||
[" clamp-tcp-mss=", "yes | no"],
|
||
[" allow-fast-path=", "yes | no"],
|
||
["/ip address add", ""],
|
||
[" address=x.x.x.x/30", "внутренний IP туннеля"],
|
||
[" interface=<name>", ""],
|
||
].map(([cmd, desc], i) => (
|
||
<div key={i} className="flex gap-2 py-0.5">
|
||
<span className="text-foreground/70 shrink-0">{cmd}</span>
|
||
{desc && <span className="text-muted-foreground"># {desc}</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
||
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
|
||
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
|
||
{codePreviewTunnel && (() => {
|
||
const code = generateRosCommands(codePreviewTunnel, serverById)
|
||
return (
|
||
<>
|
||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
|
||
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
|
||
</div>
|
||
<Button
|
||
variant="outline" size="sm"
|
||
className="shrink-0 gap-1.5"
|
||
onClick={() => handleCopy(code)}
|
||
>
|
||
{copied
|
||
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
|
||
: <><CopyIcon className="size-3.5" /> Копировать</>}
|
||
</Button>
|
||
</div>
|
||
</SheetHeader>
|
||
|
||
<div className="flex-1 overflow-y-auto">
|
||
{/* meta strip */}
|
||
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
|
||
<span className="flex items-center gap-1.5">
|
||
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
|
||
{STATUS_MAP[codePreviewTunnel.status].label}
|
||
</span>
|
||
<span className="text-muted-foreground">·</span>
|
||
<span>{serverById[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-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* code block */}
|
||
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
|
||
{code.split("\n").map((line, i) => {
|
||
const isComment = line.startsWith("#")
|
||
const isSection = isComment && line.includes("──")
|
||
const isKey = /^\s+[a-z]/.test(line)
|
||
return (
|
||
<span key={i} className={
|
||
isSection ? "text-muted-foreground/60"
|
||
: isComment ? "text-muted-foreground"
|
||
: isKey ? "text-sky-400/90"
|
||
: "text-foreground"
|
||
}>
|
||
{line}
|
||
{"\n"}
|
||
</span>
|
||
)
|
||
})}
|
||
</pre>
|
||
</div>
|
||
|
||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
|
||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||
{copied ? "Скопировано" : "Копировать команды"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</>
|
||
)
|
||
})()}
|
||
</SheetContent>
|
||
</Sheet>
|
||
|
||
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
||
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
|
||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||
<SheetTitle>Новый GRE-туннель</SheetTitle>
|
||
<SheetDescription>RouterOS 7.20+ · /interface gre add</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<div className="flex-1 overflow-y-auto px-6 py-5 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" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Сервер (MikroTik)" required>
|
||
<select value={tForm.serverId} onChange={(e) => setT("serverId", 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="" disabled>Выбрать сервер…</option>
|
||
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||
</select>
|
||
</FormField>
|
||
<FormField label="Комментарий">
|
||
<Input placeholder="Описание туннеля" 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 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" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Внутренний IP</SectionTitle>
|
||
<FormField label="IP-пул" required hint="Из какого пула выделяется /30-блок">
|
||
<select value={tForm.poolId} onChange={(e) => setT("poolId", 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="" disabled>Выбрать пул…</option>
|
||
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||
</select>
|
||
</FormField>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<FormField label="Локальный IP" required hint="/ip address на этом конце">
|
||
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Удалённый IP" required hint="/ip address на другом конце">
|
||
<Input className="font-mono" 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 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"
|
||
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
|
||
<button type="button" 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>
|
||
<FormField label="IKE-версия">
|
||
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
|
||
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
|
||
</FormField>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<FormField label="Шифрование">
|
||
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
|
||
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">
|
||
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||
</select>
|
||
</FormField>
|
||
<FormField label="Хеш-алгоритм">
|
||
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
|
||
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">
|
||
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||
</select>
|
||
</FormField>
|
||
</div>
|
||
<FormField label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
||
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
|
||
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">
|
||
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||
</select>
|
||
</FormField>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<FormField label="Срок жизни SA" hint="Формат: 1d 00:00:00">
|
||
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
|
||
</FormField>
|
||
<div className="flex items-center justify-between pt-6">
|
||
<span className="text-sm font-medium">PFS</span>
|
||
<FormToggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
|
||
</div>
|
||
</div>
|
||
</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 type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
|
||
</FormField>
|
||
<FormField label="Keepalive, с" hint="0 = откл.">
|
||
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
||
</FormField>
|
||
<FormField label="Попытки">
|
||
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
||
</FormField>
|
||
</div>
|
||
<FormField label="DSCP">
|
||
<select 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: "Аппаратное ускорение трафика" },
|
||
].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 checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||
<Button className="flex-1" onClick={() => setTunnelOpen(false)}>Создать туннель</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
|
||
{/* ══ Sheet: Add Pool ════════════════════════════════════════════════════ */}
|
||
<Sheet open={poolOpen} onOpenChange={setPoolOpen}>
|
||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||
<SheetTitle>Новый IP-пул</SheetTitle>
|
||
<SheetDescription>Пул адресов для назначения внутренних IP GRE-туннелям</SheetDescription>
|
||
</SheetHeader>
|
||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Параметры пула</SectionTitle>
|
||
<FormField label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
|
||
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
|
||
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
|
||
</FormField>
|
||
<FormField label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
|
||
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
|
||
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
|
||
</FormField>
|
||
<FormField label="Назначение / Комментарий">
|
||
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
|
||
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
|
||
</FormField>
|
||
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
|
||
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
|
||
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
|
||
return blocks > 0 ? (
|
||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
|
||
<p className="text-muted-foreground">
|
||
Доступно <span className="font-semibold text-foreground font-mono">{blocks}</span> блоков /30
|
||
{" "}= до <span className="font-semibold text-foreground font-mono">{blocks}</span> туннелей
|
||
</p>
|
||
</div>
|
||
) : null
|
||
})()}
|
||
</div>
|
||
</div>
|
||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||
<Button className="flex-1" onClick={() => setPoolOpen(false)}>Создать пул</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
)
|
||
}
|