fix(ui): улучшить мобильные экраны и сводку GRE и VXLAN

This commit is contained in:
Denozordec committed 2026-09-30 16:27:10 +07:00
1 parent fc3d588cec
commit a29ee31052
15 files changed
+116 -111

No files matched your search

+21 -54
View File
@@ -492,7 +492,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
setTunnelOpen(true)
}
const incomplete = isLive && (!hasLoaded || !!dataError || failures.length > 0)
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(() => {
@@ -511,8 +511,8 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
})
}, [tabFilter, search, scopedTunnels, serverById, unknownServerIds])
const upCount = displayTunnels.filter((t) => t.status === "up").length
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
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
@@ -587,7 +587,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<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>
<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>
@@ -607,27 +607,14 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
</Alert>
)}
{/* 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>
<KpiStatGrid
aria-label="Сводка GRE"
gridClassName="grid-cols-2 @3xl:grid-cols-2 @6xl:grid-cols-4"
items={[
{
id: "tunnels",
label: "Всего туннелей",
value: incomplete ? "—" : displayTunnels.length,
label: "Туннелей",
value: incomplete ? "—" : scopedTunnels.length,
hint: incomplete ? "неизвестно" : undefined,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
@@ -642,7 +629,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
},
{
id: "ipsec",
label: "Защищены IPsec",
label: "С IPsec",
value: incomplete ? "—" : ipsecCount,
hint: incomplete ? "неизвестно" : undefined,
icon: <LockIcon className="size-4" />,
@@ -750,35 +737,15 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
)}
{/* RouterOS reference */}
<OpsPanel title="RouterOS 7.20+ — параметры GRE-интерфейса" contentClassName="px-5 py-4">
<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>
<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"}
@@ -822,7 +789,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
<Sheet open={tunnelOpen} onOpenChange={v => { if (!formLock.current) setTunnelOpen(v) }}>
<SheetContent showCloseButton={!mutateBusy} side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<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>
@@ -839,7 +806,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<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}){isLive && !canManage(s.id) ? " — недоступен для изменений" : ""}</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="Комментарий">
@@ -865,11 +832,11 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<SectionTitle>Внутренний IP</SectionTitle>
{!isLive && <p className="text-xs text-muted-foreground">Моковые пулы доступны только для просмотра.</p>}
<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 label="Локальный IP" hint="/ip address на этом конце">
<Input className="font-mono" aria-label="Локальный IP" 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 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>
@@ -912,17 +879,17 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<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))} />
<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="0 = откл.">
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
<Input aria-label="Keepalive, с" 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))} />
<Input aria-label="Попытки" 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)}
<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>)}
+6 -53
View File
@@ -86,6 +86,7 @@ function ExportSheet({
<CodeExportSheet
open={open}
onClose={onClose}
className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
title="Экспорт VXLAN"
description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
formats={[
@@ -297,6 +298,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
<KpiStatGrid
aria-label="Сводка VXLAN"
gridClassName="grid-cols-2 @3xl:grid-cols-2 @6xl:grid-cols-4"
items={[
{
id: "tunnels",
@@ -314,7 +316,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
},
{
id: "vni",
label: "Уникальных VNI",
label: "VNI",
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-info",
@@ -334,8 +336,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
<div>
<p className="font-medium text-sky-600 dark:text-sky-400">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p>
<p className="text-muted-foreground text-xs mt-0.5">
Доступен с RouterOS 7.1+. VNI (Virtual Network Identifier) — уникальный идентификатор сегмента (0–16777215).
Рекомендуется использовать совместно с WireGuard или GRE туннелями для шифрования.
VXLAN переносит L2 через IP-сеть. VNI определяет сегмент; шифрование зависит от настроенного транспорта.
</p>
</div>
</div>
@@ -355,56 +356,8 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
/>}
</DataPageCard>
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать VXLAN",
lines: [
"/interface/vxlan/add \\",
" name=vxlan-10 \\",
" vni=10010 \\",
" port=8472 \\",
" arp-proxy=yes \\",
" mac-learning=yes",
],
},
{
title: "Добавить VTEP",
lines: [
"/interface/vxlan/vteps/add \\",
" interface=vxlan-10 \\",
" remote-ip=10.0.1.1",
"",
"/interface/vxlan/vteps/add \\",
" interface=vxlan-10 \\",
" remote-ip=10.0.2.1",
],
},
{
title: "Bridge + IP",
lines: [
"/interface/bridge/add \\",
" name=br-overlay",
"",
"/interface/bridge/port/add \\",
" bridge=br-overlay \\",
" interface=vxlan-10",
"",
"/ip/address/add \\",
" address=10.100.0.1/24 \\",
" interface=br-overlay",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
<OpsPanel title="Справка VXLAN" contentClassName="px-5 py-4">
<p className="text-xs text-muted-foreground">Экспорт содержит интерфейс и статические VTEP выбранного туннеля. Bridge и FDB не входят в доступный снимок. <a href="https://help.mikrotik.com/docs/spaces/ROS/pages/100007937/VXLAN" target="_blank" rel="noreferrer" className="underline underline-offset-2">Параметры VXLAN в документации MikroTik</a></p>
</OpsPanel>
</div>