diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx index 9c63a19a..52f06a00 100644 --- a/app/(main)/gre/page.tsx +++ b/app/(main)/gre/page.tsx @@ -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 {selectedTunnel ? "Выбран GRE-туннель: " + selectedTunnel.name : "GRE-туннель по ссылке"} -

{isLive && dataLoading ? "Загрузка туннеля…" : selectedTunnelError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · " + selectedTunnel.remoteAddress + ". Список ограничен выбранным туннелем; сводка относится ко всем серверам." : "Туннель не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}

+

{isLive && dataLoading ? "Загрузка туннеля…" : selectedTunnelError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · " + selectedTunnel.remoteAddress + ". Список ограничен выбранным туннелем; сводка относится к этому срезу." : "Туннель не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}

Все туннели сервера К дашборду @@ -607,27 +607,14 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on )} - {/* Legacy banner */} -
- -
-

- В RouterOS 7.x рекомендуется использовать WireGuard вместо GRE+IPsec -

-

- WireGuard проще в настройке, обеспечивает лучшую производительность и современную криптографию (ChaCha20-Poly1305). - GRE-туннели остаются поддерживаемыми, но WireGuard — предпочтительный выбор для новых развёртываний. -

-
-
- , 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: , @@ -750,35 +737,15 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on )} {/* RouterOS reference */} - -
- {[ - ["/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=", ""], - ].map(([cmd, desc], i) => ( -
- {cmd} - {desc && # {desc}} -
- ))} -
+ +

Экспорт содержит интерфейс и локальный IP выбранного туннеля. Удалённую сторону и маршруты настройте отдельно. Параметры GRE в документации MikroTik

{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */} setCodePreviewTunnel(null)} title={codePreviewTunnel?.name ?? "GRE"} @@ -822,7 +789,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on {/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */} { if (!formLock.current) setTunnelOpen(v) }}> - + {tunnelMode === "edit" ? "Редактировать GRE-туннель" : "Новый GRE-туннель"} RouterOS 7.20+ · /interface gre {tunnelMode === "edit" ? "set" : "add"} @@ -839,7 +806,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on @@ -865,11 +832,11 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on Внутренний IP {!isLive &&

Моковые пулы доступны только для просмотра.

}
- - setT("localInnerIp", e.target.value)} /> + + setT("localInnerIp", e.target.value)} /> - - setT("remoteInnerIp", e.target.value)} /> + + setT("remoteInnerIp", e.target.value)} />
@@ -912,17 +879,17 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
- setT("mtu", Number(e.target.value))} /> + setT("mtu", Number(e.target.value))} /> - setT("keepaliveInterval", Number(e.target.value))} /> + setT("keepaliveInterval", Number(e.target.value))} /> - setT("keepaliveRetries", Number(e.target.value))} /> + setT("keepaliveRetries", Number(e.target.value))} />
- 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"> {Array.from({ length: 64 }, (_, i) => )} diff --git a/app/(main)/vxlan/page.tsx b/app/(main)/vxlan/page.tsx index d152da1e..71f80cb2 100644 --- a/app/(main)/vxlan/page.tsx +++ b/app/(main)/vxlan/page.tsx @@ -86,6 +86,7 @@ function ExportSheet({ 0) ? "—" : vnis, icon: , iconClassName: "text-info", @@ -334,8 +336,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,

VXLAN — L2-over-L3 оверлей для RouterOS 7.x

- Доступен с RouterOS 7.1+. VNI (Virtual Network Identifier) — уникальный идентификатор сегмента (0–16777215). - Рекомендуется использовать совместно с WireGuard или GRE туннелями для шифрования. + VXLAN переносит L2 через IP-сеть. VNI определяет сегмент; шифрование зависит от настроенного транспорта.

@@ -355,56 +356,8 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, />} - -
- {[ - { - 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) => ( -
-

{b.title}

-
-                      {b.lines.join("\n")}
-                    
-
- ))} -
+ +

Экспорт содержит интерфейс и статические VTEP выбранного туннеля. Bridge и FDB не входят в доступный снимок. Параметры VXLAN в документации MikroTik

diff --git a/components/reui-kit/kpi-stat-grid.tsx b/components/reui-kit/kpi-stat-grid.tsx index 5e63eb2c..13069935 100644 --- a/components/reui-kit/kpi-stat-grid.tsx +++ b/components/reui-kit/kpi-stat-grid.tsx @@ -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 (
-
+
{list.map((item, index) => ( ))} @@ -291,7 +293,7 @@ export function KpiStatGrid({ return ( -
+
{list.map((item, index) => ( ))} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 9ce85946..62116e8c 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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 остаются отдельной приёмкой; актуальная точка продолжения — в журнале. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 7ece17d4..48b0caf1 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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 не изобретены. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 88c14c02..5f9de728 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -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. + diff --git a/docs/ux-ui/screens/gre-desktop.png b/docs/ux-ui/screens/gre-desktop.png new file mode 100644 index 00000000..65c9ab4a Binary files /dev/null and b/docs/ux-ui/screens/gre-desktop.png differ diff --git a/docs/ux-ui/screens/gre-form-desktop.png b/docs/ux-ui/screens/gre-form-desktop.png new file mode 100644 index 00000000..29d5441b Binary files /dev/null and b/docs/ux-ui/screens/gre-form-desktop.png differ diff --git a/docs/ux-ui/screens/gre-form-mobile.png b/docs/ux-ui/screens/gre-form-mobile.png new file mode 100644 index 00000000..ffd958ff Binary files /dev/null and b/docs/ux-ui/screens/gre-form-mobile.png differ diff --git a/docs/ux-ui/screens/gre-mobile.png b/docs/ux-ui/screens/gre-mobile.png new file mode 100644 index 00000000..29d7fb28 Binary files /dev/null and b/docs/ux-ui/screens/gre-mobile.png differ diff --git a/docs/ux-ui/screens/vxlan-desktop.png b/docs/ux-ui/screens/vxlan-desktop.png new file mode 100644 index 00000000..9c4310fa Binary files /dev/null and b/docs/ux-ui/screens/vxlan-desktop.png differ diff --git a/docs/ux-ui/screens/vxlan-export-desktop.png b/docs/ux-ui/screens/vxlan-export-desktop.png new file mode 100644 index 00000000..ca7302bd Binary files /dev/null and b/docs/ux-ui/screens/vxlan-export-desktop.png differ diff --git a/docs/ux-ui/screens/vxlan-export-mobile.png b/docs/ux-ui/screens/vxlan-export-mobile.png new file mode 100644 index 00000000..83f001c1 Binary files /dev/null and b/docs/ux-ui/screens/vxlan-export-mobile.png differ diff --git a/docs/ux-ui/screens/vxlan-mobile.png b/docs/ux-ui/screens/vxlan-mobile.png new file mode 100644 index 00000000..01ef21c5 Binary files /dev/null and b/docs/ux-ui/screens/vxlan-mobile.png differ diff --git a/tests/e2e/gre-vxlan-layout.spec.ts b/tests/e2e/gre-vxlan-layout.spec.ts new file mode 100644 index 00000000..2a9390e8 --- /dev/null +++ b/tests/e2e/gre-vxlan-layout.spec.ts @@ -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) + }) +}