865 lines
49 KiB
TypeScript
865 lines
49 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { greTunnels, grePools, servers } from "@/lib/data"
|
||
import type { GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion } from "@/lib/data"
|
||
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, SearchIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||
} 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" },
|
||
}
|
||
|
||
const serverById = Object.fromEntries(servers.map((s) => [s.id, s]))
|
||
const poolById = Object.fromEntries(grePools.map((p) => [p.id, p]))
|
||
|
||
// ─── RouterOS code generator ─────────────────────────────────────────────────
|
||
|
||
function generateRosCommands(t: GreTunnel): string {
|
||
const lines: string[] = []
|
||
const srv = serverById[t.serverId]
|
||
|
||
lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`)
|
||
lines.push(`# Сгенерировано RouterLists · ${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>
|
||
)
|
||
}
|
||
|
||
function Field({ label, hint, required, children }: {
|
||
label: string; hint?: string; required?: boolean; children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-sm font-medium">
|
||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
||
</label>
|
||
{children}
|
||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={checked}
|
||
onClick={() => onChange(!checked)}
|
||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}
|
||
>
|
||
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<div className="flex items-center gap-2 py-1">
|
||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
||
<div className="flex-1 h-px bg-border" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
||
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
||
{options.map((o) => (
|
||
<button key={o.value} type="button" onClick={() => onChange(o.value)}
|
||
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 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 [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 filtered = useMemo(() => {
|
||
return greTunnels.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])
|
||
|
||
const upCount = greTunnels.filter((t) => t.status === "up").length
|
||
const ipsecCount = greTunnels.filter((t) => t.ipsec).length
|
||
|
||
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
||
{ value: "all", label: "Все", count: greTunnels.length },
|
||
{ value: "up", label: "Активные", count: upCount },
|
||
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
|
||
{ value: "plain", label: "Без IPsec", count: greTunnels.length - ipsecCount },
|
||
]
|
||
|
||
function handleCopy(code: string) {
|
||
navigator.clipboard.writeText(code).then(() => {
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||
actions={
|
||
<>
|
||
<Button variant="outline" size="sm"><RefreshCwIcon className="size-4" />Обновить статус</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: greTunnels.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: grePools.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" && (
|
||
<Card>
|
||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||
{tunnelTabs.map((t) => (
|
||
<button key={t.value} onClick={() => setTabFilter(t.value)}
|
||
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${tabFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
||
{t.label}
|
||
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||
placeholder="Поиск по имени, IP…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||
</div>
|
||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
||
</div>
|
||
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||
<th className="text-left font-medium px-5 py-3">Интерфейс / Сервер</th>
|
||
<th className="text-left font-medium px-4 py-3">Эндпоинты</th>
|
||
<th className="text-left font-medium px-4 py-3">Внутренний IP</th>
|
||
<th className="text-left font-medium px-4 py-3">Пул</th>
|
||
<th className="text-left font-medium px-4 py-3">IPsec</th>
|
||
<th className="text-left font-medium px-4 py-3">Шифрование</th>
|
||
<th className="text-center font-medium px-4 py-3">MTU</th>
|
||
<th className="text-left font-medium px-4 py-3">Keepalive</th>
|
||
<th className="text-left font-medium px-4 py-3">Статус</th>
|
||
<th className="w-20 px-3 py-3" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{filtered.map((t) => {
|
||
const srv = serverById[t.serverId]
|
||
const pool = poolById[t.poolId]
|
||
return (
|
||
<tr key={t.id} className="hover:bg-muted/40 transition-colors">
|
||
<td className="px-5 py-3">
|
||
<p className="font-medium font-mono text-[13px]">{t.name}</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||
{srv && <Flag code={srv.country} />}
|
||
{srv?.name ?? t.serverId}
|
||
</p>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<p className="font-mono text-xs">
|
||
{t.localAddress === "0.0.0.0" ? <span className="text-muted-foreground">авто</span> : t.localAddress}
|
||
</p>
|
||
<p className="font-mono text-xs text-muted-foreground">→ {t.remoteAddress}</p>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<p className="font-mono text-xs">{t.localInnerIp}</p>
|
||
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span className="text-xs text-muted-foreground font-mono">{pool?.name ?? "—"}</span>
|
||
</td>
|
||
<td className="px-4 py-3"><IpsecBadge secured={!!t.ipsec} /></td>
|
||
<td className="px-4 py-3">
|
||
{t.ipsec ? (
|
||
<div className="flex flex-col gap-0.5">
|
||
<span className="text-xs font-mono">{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}</span>
|
||
<span className="text-xs text-muted-foreground font-mono">
|
||
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}{t.ipsec.pfs && " · PFS"}
|
||
</span>
|
||
</div>
|
||
) : <span className="text-xs text-muted-foreground">—</span>}
|
||
</td>
|
||
<td className="px-4 py-3 text-center font-mono text-xs">{t.mtu}</td>
|
||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
|
||
</td>
|
||
<td className="px-4 py-3"><TunnelStatus status={t.status} /></td>
|
||
|
||
{/* actions */}
|
||
<td className="px-3 py-3">
|
||
<div className="flex items-center gap-1 justify-end">
|
||
{/* Code preview button */}
|
||
<Button
|
||
variant="ghost" size="icon" className="size-7"
|
||
title="Предпросмотр кода RouterOS"
|
||
onClick={() => setCodePreviewTunnel(t)}
|
||
>
|
||
<CodeXmlIcon className="size-3.5" />
|
||
</Button>
|
||
|
||
{/* Actions dropdown */}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger render={
|
||
<Button variant="ghost" size="icon" className="size-7">
|
||
<MoreHorizontalIcon className="size-4" />
|
||
</Button>
|
||
} />
|
||
<DropdownMenuContent side="bottom" align="end">
|
||
<DropdownMenuGroup>
|
||
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
|
||
</DropdownMenuGroup>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem onClick={() => setCodePreviewTunnel(t)}>
|
||
<CodeXmlIcon className="size-4" /> Просмотр кода
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem>
|
||
<PencilIcon className="size-4" /> Редактировать
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem>
|
||
<PowerIcon className="size-4" />
|
||
{t.enabled ? "Выключить" : "Включить"}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem variant="destructive">
|
||
<Trash2Icon className="size-4" /> Удалить туннель
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* ── IP Pools ── */}
|
||
{pageTab === "pools" && (
|
||
<Card>
|
||
<div className="flex items-center justify-between px-5 py-3 border-b">
|
||
<span className="text-sm text-muted-foreground">{grePools.length} пула</span>
|
||
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
||
<PlusIcon className="size-4" />Добавить пул
|
||
</Button>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||
<th className="text-left font-medium px-5 py-3">Имя пула</th>
|
||
<th className="text-left font-medium px-4 py-3">Диапазон CIDR</th>
|
||
<th className="text-right font-medium px-4 py-3">Назначено /30</th>
|
||
<th className="text-right font-medium px-4 py-3">Доступно /30</th>
|
||
<th className="text-left font-medium px-4 py-3">Использование</th>
|
||
<th className="text-left font-medium px-4 py-3">Назначение</th>
|
||
<th className="w-10 px-3 py-3" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{grePools.map((pool) => {
|
||
const pct = Math.round((pool.allocated / pool.total) * 100)
|
||
return (
|
||
<tr key={pool.id} className="hover:bg-muted/40 transition-colors">
|
||
<td className="px-5 py-3 font-mono text-[13px] font-medium">{pool.name}</td>
|
||
<td className="px-4 py-3 font-mono text-xs">{pool.cidr}</td>
|
||
<td className="px-4 py-3 text-right tabular-nums">{pool.allocated}</td>
|
||
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{pool.total - pool.allocated}</td>
|
||
<td className="px-4 py-3 min-w-[140px]">
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||
<div className={`h-full rounded-full ${pct > 80 ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${pct}%` }} />
|
||
</div>
|
||
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-muted-foreground text-xs">{pool.comment}</td>
|
||
<td className="px-3 py-3">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger render={
|
||
<Button variant="ghost" size="icon" className="size-7">
|
||
<MoreHorizontalIcon className="size-4" />
|
||
</Button>
|
||
} />
|
||
<DropdownMenuContent side="bottom" align="end">
|
||
<DropdownMenuItem><PencilIcon className="size-4" /> Редактировать</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" /> Удалить пул</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<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">
|
||
{grePools.map((pool) => {
|
||
const poolTunnels = greTunnels.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) => (
|
||
<div key={t.id} 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>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 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)
|
||
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>
|
||
<Field label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
|
||
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
|
||
</Field>
|
||
<Field 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-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||
<option value="" disabled>Выбрать сервер…</option>
|
||
{servers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Комментарий">
|
||
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
|
||
</Field>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">Включён</span>
|
||
<Toggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Эндпоинты</SectionTitle>
|
||
<Field 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)} />
|
||
</Field>
|
||
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
|
||
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Внутренний IP</SectionTitle>
|
||
<Field 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-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||
<option value="" disabled>Выбрать пул…</option>
|
||
{grePools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||
</select>
|
||
</Field>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field 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)} />
|
||
</Field>
|
||
<Field 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)} />
|
||
</Field>
|
||
</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>
|
||
<Toggle 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">
|
||
<Field 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>
|
||
</Field>
|
||
<Field label="IKE-версия">
|
||
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
|
||
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
|
||
</Field>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field 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-transparent px-2.5 text-sm 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>
|
||
</Field>
|
||
<Field 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-transparent px-2.5 text-sm 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>
|
||
</Field>
|
||
</div>
|
||
<Field 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-transparent px-2.5 text-sm 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>
|
||
</Field>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Срок жизни SA" hint="Формат: 1d 00:00:00">
|
||
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
|
||
</Field>
|
||
<div className="flex items-center justify-between pt-6">
|
||
<span className="text-sm font-medium">PFS</span>
|
||
<Toggle 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">
|
||
<Field label="MTU" hint="По умолч. 1476">
|
||
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
|
||
</Field>
|
||
<Field label="Keepalive, с" hint="0 = откл.">
|
||
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
||
</Field>
|
||
<Field label="Попытки">
|
||
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
||
</Field>
|
||
</div>
|
||
<Field label="DSCP">
|
||
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
|
||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm 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>
|
||
</Field>
|
||
{[
|
||
{ 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>
|
||
<Toggle 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>
|
||
<Field 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 }))} />
|
||
</Field>
|
||
<Field 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 }))} />
|
||
</Field>
|
||
<Field label="Назначение / Комментарий">
|
||
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
|
||
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
|
||
</Field>
|
||
{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>
|
||
)
|
||
}
|