fix(ui): улучшить мобильные экраны и сводку GRE и VXLAN
No files matched your search
@@ -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>
|
||||
|
||||
@@ -203,6 +203,7 @@ interface KpiStatGridProps {
|
||||
emptyMessage?: ReactNode
|
||||
emptyIcon?: ReactNode
|
||||
className?: string
|
||||
gridClassName?: string
|
||||
skeletonCount?: number
|
||||
embedded?: boolean
|
||||
"aria-label"?: string
|
||||
@@ -255,6 +256,7 @@ export function KpiStatGrid({
|
||||
emptyIcon,
|
||||
className,
|
||||
skeletonCount = 4,
|
||||
gridClassName,
|
||||
embedded = false,
|
||||
"aria-label": ariaLabel,
|
||||
}: KpiStatGridProps) {
|
||||
@@ -280,7 +282,7 @@ export function KpiStatGrid({
|
||||
if (embedded) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn("@container w-full", className)}>
|
||||
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||
<div className={cn("grid gap-2", kpiCols(list.length || 1), gridClassName)}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||
))}
|
||||
@@ -291,7 +293,7 @@ export function KpiStatGrid({
|
||||
|
||||
return (
|
||||
<Frame className={cn("@container w-full min-w-0", className)} aria-label={ariaLabel}>
|
||||
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||
<div className={cn("grid gap-2", kpiCols(list.length || 1), gridClassName)}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
|
||||
@@ -126,3 +126,14 @@ flowchart LR
|
||||
Для новой задачи:
|
||||
|
||||
> Продолжи UX/UI-план из docs/ux-ui-plan.md по журналу docs/ux-ui-progress.md. Сначала проверь состояние файлов и остаток лимита. Выполни следующий незавершённый блок, проверь результат, обнови журнал и сделай локальный коммит без push.
|
||||
|
||||
## GRE/VXLAN — локальная визуальная приёмка 2026-09-30
|
||||
|
||||
| Экран / референс | ПК | Телефон | Схема |
|
||||
| --- | --- | --- | --- |
|
||||
| GRE · List 9 / Stats 12 | [Снимок](ux-ui/screens/gre-desktop.png) | [Снимок](ux-ui/screens/gre-mobile.png) | Устройство → сводка среза → туннели |
|
||||
| GRE · Sheet 8 | [Форма](ux-ui/screens/gre-form-desktop.png) | [Форма](ux-ui/screens/gre-form-mobile.png) | Контекст → поддерживаемые поля → подтверждение API |
|
||||
| VXLAN · Data Grid Filtering 2 | [Снимок](ux-ui/screens/vxlan-desktop.png) | [Снимок](ux-ui/screens/vxlan-mobile.png) | Устройство → подтверждённый срез → просмотр |
|
||||
| VXLAN · Sheet 8 | [Экспорт](ux-ui/screens/vxlan-export-desktop.png) | [Экспорт](ux-ui/screens/vxlan-export-mobile.png) | Снимок → актуальность → код → копирование/файл |
|
||||
|
||||
HTTP-фикстуры; layout/клавиатура проверены. Production frontend build и 183 E2E прошли в 9.63. RouterOS/JWT остаются отдельной приёмкой; актуальная точка продолжения — в журнале.
|
||||
@@ -25,12 +25,19 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.62 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.63 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.63 — приёмка GRE/VXLAN и мобильные панели (2026-09-30)
|
||||
|
||||
- GRE/VXLAN используют компактную сводку в две колонки на телефоне и краткие подписи. GRE KPI теперь относится к выбранному устройству/туннелю, а не глобальному списку. Неизвестная сводка учитывает выбранный срез. Для этого KpiStatGrid получил необязательный gridClassName; существующие потребители сохраняют прежнюю раскладку.
|
||||
- Форма GRE и экспорт имеют ширину телефона; footer больше не обрезает «Копировать». На ПК панели ограничены шириной lg. GRE-поля получили доступные имена, optional IP не помечены обязательными; пустой site не выводит скобки. Убраны устаревшие справочные команды VXLAN arp-proxy/mac-learning и лишний баннер GRE; справка ведёт к MikroTik. VXLAN не обещает шифрование через обычный GRE.
|
||||
- Просмотрены восемь PNG в docs/ux-ui/screens: GRE/VXLAN и формы/экспорт для ПК/телефона. Четыре сценария keyboard/layout проверили точный serverId/VNI, сохранение, отсутствие переполнения, полную мобильную ширину и границы кнопок footer. Общая регрессия этапа 9: 183 E2E прошли (source-конфигурации проверяются отдельно). Production frontend build — 34 страницы; TypeScript/lint/diff-check чистые. Backend test:live-maps, test:config-sync и unit экспортов прошли.
|
||||
- Перегенерированные прежние Filters/IPsec PNG восстановлены к своим закоммиченным вариантам; новых продуктовых изменений там нет. Далее — отдельное исправление serverId GRE API и фиксация границ завершения этапа 9. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
|
||||
|
||||
## Блок 9.62 — возможности API GRE-пулов (2026-09-30)
|
||||
|
||||
- Удалён фиктивный live-каталог из poolId интерфейсов: он не содержит CIDR/ёмкость, но раньше изображал полное заполнение и свободные блоки. Live KPI пулов теперь неизвестен с подписью «нет данных API», раздел объясняет отсутствие каталога/управления. Инвентаризация настоящих /ip/pool и её API не изобретены.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Этап 9: VPN и сетевые операции
|
||||
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.25. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.63. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
|
||||
## Схема и референсы принятого плана
|
||||
|
||||
@@ -361,3 +361,11 @@ Live-копирование читает актуальный целевой rul
|
||||
- Семь E2E прошли: числовая сортировка/поиск 12 туннелей, отсутствие подмены неизвестных пулов пустыми, mock-каталог/no writes и три формы. TypeScript/lint чистые.
|
||||
- Далее — desktop/mobile GRE/VXLAN и профильная регрессия. Расширение backend настоящим управлением адресными пулами — отдельный функциональный объём, текущий UX честно отражает доступные API. Локальный коммит без push.
|
||||
|
||||
|
||||
## Блок 9.63 — приёмка GRE/VXLAN и мобильные панели (2026-09-30)
|
||||
|
||||
- GRE/VXLAN используют компактную сводку в две колонки на телефоне и краткие подписи. GRE KPI теперь относится к выбранному устройству/туннелю, а не глобальному списку. Неизвестная сводка учитывает выбранный срез. Для этого KpiStatGrid получил необязательный gridClassName; существующие потребители сохраняют прежнюю раскладку.
|
||||
- Форма GRE и экспорт имеют ширину телефона; footer больше не обрезает «Копировать». На ПК панели ограничены шириной lg. GRE-поля получили доступные имена, optional IP не помечены обязательными; пустой site не выводит скобки. Убраны устаревшие справочные команды VXLAN arp-proxy/mac-learning и лишний баннер GRE; справка ведёт к MikroTik. VXLAN не обещает шифрование через обычный GRE.
|
||||
- Просмотрены восемь PNG в docs/ux-ui/screens: GRE/VXLAN и формы/экспорт для ПК/телефона. Четыре сценария keyboard/layout проверили точный serverId/VNI, сохранение, отсутствие переполнения, полную мобильную ширину и границы кнопок footer. Общая регрессия этапа 9: 183 E2E прошли (source-конфигурации проверяются отдельно). Production frontend build — 34 страницы; TypeScript/lint/diff-check чистые. Backend test:live-maps, test:config-sync и unit экспортов прошли.
|
||||
- Перегенерированные прежние Filters/IPsec PNG восстановлены к своим закоммиченным вариантам; новых продуктовых изменений там нет. Далее — отдельное исправление serverId GRE API и фиксация границ завершения этапа 9. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
|
||||
|
||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,57 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import { mkdirSync } from "node:fs"
|
||||
|
||||
for (const section of ["gre", "vxlan"]) for (const device of ["desktop", "mobile"] as const) {
|
||||
test(`${section}: клавиатура и layout ${device}`, async ({ page }) => {
|
||||
await page.setViewportSize(device === "desktop" ? { width: 1440, height: 1000 } : { width: 390, height: 844 })
|
||||
await page.clock.install({ time: new Date("2026-09-30T05:00:00Z") })
|
||||
const devices = [1, 2].map(id => ({ id, name: id === 1 ? "Основной роутер" : "Резервный роутер", host: "192.0.2." + id, enabled: true, country: "RU", status: "online", type: "exit-node" }))
|
||||
const gre = ["1", "2"].map(id => ({ id: "*" + id, serverId: id, name: "gre-" + id, localAddress: "192.0.2." + id, remoteAddress: "198.51.100." + id, localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "live", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" }))
|
||||
const vxlan = ["1", "2"].map(id => ({ id: "*" + id, serverId: id, name: "vxlan-" + id, vni: Number(id) * 100, port: 0, dstPort: 8472, vtepIp: "192.0.2." + id, remoteVteps: ["198.51.100." + id], l2mtu: 1500, arpProxy: false, macLearning: true, comment: "", enabled: true, status: "up" }))
|
||||
let writes = 0
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", r => {
|
||||
const path = new URL(r.request().url()).pathname
|
||||
if (r.request().method() === "PATCH") {
|
||||
writes++
|
||||
expect(path).toBe("/api/gre/tunnels")
|
||||
expect(r.request().postDataJSON()).toMatchObject({ serverId: "2", rosId: "*2", comment: "Проверенный контекст" })
|
||||
gre[1].comment = "Проверенный контекст"
|
||||
return r.fulfill({ json: { ok: true } })
|
||||
}
|
||||
return r.fulfill({ json: path === "/api/servers" ? devices : path === "/api/gre/tunnels" ? { tunnels: gre } : path === "/api/vxlan" ? { tunnels: vxlan } : path === "/api/auth/config" ? { required: false } : {} })
|
||||
})
|
||||
await page.goto("/" + section + "?server=2")
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
|
||||
mkdirSync("docs/ux-ui/screens", { recursive: true })
|
||||
await page.screenshot({ path: `docs/ux-ui/screens/${section}-${device}.png`, animations: "disabled" })
|
||||
const menu = page.getByRole("button", { name: "Действия: " + section + "-2", exact: true })
|
||||
await menu.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
await page.getByRole("menuitem", { name: section === "gre" ? "Редактировать" : "Экспорт .rsc", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect.poll(() => dialog.evaluate(el => el.getBoundingClientRect().left >= 0 && el.getBoundingClientRect().right <= innerWidth)).toBe(true)
|
||||
if (device === "mobile") {
|
||||
await expect.poll(() => dialog.evaluate(el => el.getBoundingClientRect().width >= innerWidth - 1)).toBe(true)
|
||||
const footer = dialog.locator('[data-slot="sheet-footer"]')
|
||||
expect(await footer.evaluate(el => [...el.querySelectorAll("button")].every(button => button.getBoundingClientRect().right <= innerWidth && button.getBoundingClientRect().left >= 0))).toBe(true)
|
||||
}
|
||||
if (section === "gre") {
|
||||
await expect(dialog.getByLabel("Сервер (MikroTik)")).toHaveValue("2")
|
||||
await dialog.getByLabel("Комментарий").fill("Проверенный контекст")
|
||||
} else {
|
||||
await expect(dialog).toContainText("Резервный роутер")
|
||||
await expect(dialog).toContainText("vni=200")
|
||||
}
|
||||
await page.screenshot({ path: `docs/ux-ui/screens/${section}-${section === "gre" ? "form" : "export"}-${device}.png`, animations: "disabled" })
|
||||
if (section === "gre") {
|
||||
await dialog.getByRole("button", { name: "Сохранить", exact: true }).focus()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
expect(writes).toBe(1)
|
||||
} else { await page.keyboard.press("Escape"); expect(writes).toBe(0) }
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||
})
|
||||
}
|
||||