fix(ui): улучшить мобильные экраны и сводку GRE и VXLAN
This commit is contained in:
1 parent
fc3d588cec
commit
a29ee31052
15 files changed
+116
-111
No files matched your search
+21
-54
@@ -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>)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user