diff --git a/app/(main)/vxlan/page.tsx b/app/(main)/vxlan/page.tsx index dc2253a0..d421d9e5 100644 --- a/app/(main)/vxlan/page.tsx +++ b/app/(main)/vxlan/page.tsx @@ -1,6 +1,6 @@ "use client" -import { Suspense, useCallback, useEffect, useMemo, useState } from "react" +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" import { Alert as ContextAlert, AlertTitle as ContextTitle, AlertDescription as ContextDescription } from "@/components/reui/alert" @@ -139,10 +139,14 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, const [liveTunnels, setLiveTunnels] = useState([]) const [liveServers, setLiveServers] = useState([]) const [loading, setLoading] = useState(true) + const [hasLoaded, setHasLoaded] = useState(false) + const loadEpoch = useRef(0) + useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive]) const [liveError, setLiveError] = useState(null) const loadLive = useCallback(async () => { if (!isLive) return + const epoch = ++loadEpoch.current setLoading(true) setLiveError(null) try { @@ -150,12 +154,16 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, requestJson(backendUrl, "/api/vxlan"), requestJson(backendUrl, "/api/servers"), ]) - setLiveTunnels(tunnelsRes.tunnels ?? []) + if (epoch !== loadEpoch.current) return + if (!Array.isArray(tunnelsRes?.tunnels) || !Array.isArray(serversRes)) throw new Error("Некорректный ответ загрузки VXLAN") + setLiveTunnels(tunnelsRes.tunnels) + setHasLoaded(true) setLiveServers(serversRes.filter((s) => s.enabled).map(mapBackendServer)) } catch (e) { + if (epoch !== loadEpoch.current) return setLiveError(e instanceof Error ? e.message : "Ошибка загрузки") } finally { - setLoading(false) + if (epoch === loadEpoch.current) setLoading(false) } }, [isLive, backendUrl]) @@ -273,6 +281,8 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, Ошибка загрузки: {liveError} )} + {isLive && loading &&

{hasLoaded ? "Обновление VXLAN…" : "Загрузка VXLAN…"}

} + {isLive && liveError && hasLoaded &&

Показаны последние полученные данные; они могут быть устаревшими.

} {selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && (
На опрошенных серверах нет VXLAN-интерфейсов @@ -290,28 +300,28 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, { id: "tunnels", label: "Туннелей", - value: scopedTunnels.length, + value: isLive && !hasLoaded ? "—" : scopedTunnels.length, icon: , iconClassName: "text-muted-foreground", }, { id: "up", label: "Активных", - value: upCount, + value: isLive && !hasLoaded ? "—" : upCount, icon: , iconClassName: "text-success", }, { id: "vni", label: "Уникальных VNI", - value: vnis, + value: isLive && !hasLoaded ? "—" : vnis, icon: , iconClassName: "text-info", }, { id: "servers", label: "Серверов", - value: new Set(scopedTunnels.map((t) => t.serverId)).size, + value: isLive && !hasLoaded ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size, icon: , iconClassName: "text-primary", }, diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 72ef5d3b..b8acb0ca 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -80,7 +80,7 @@ flowchart LR ## Текущая точка продолжения -По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.21 — операции IPsec и формы/действия GRE); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. +По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.22 — операции IPsec/GRE и состояния VXLAN); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.md). @@ -88,7 +88,7 @@ flowchart LR Ближайший оставшийся объём: -1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. Далее — VXLAN (загрузка, ошибки, повтор, контекст), затем Firewall/Filters; загрузка данных IPsec и общая приёмка остаются открытыми. +1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. Далее — частичные сбои backend VXLAN и данные по устройству, затем Firewall/Filters; загрузка данных IPsec и общая приёмка остаются открытыми. 2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения. 3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index e5bc7faa..d05e4264 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -25,12 +25,20 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.21 — операции IPsec и формы/действия GRE | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.22 — операции IPsec/GRE и состояния VXLAN | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.22 — состояния загрузки VXLAN (2026-09-30) + +- Ответ без массивов tunnels/servers отклоняется вместо подмены пустым списком. Первоначальная ошибка показывает неизвестные KPI (—); успешный пустой список остаётся допустимым. При ошибке обновления сохраняются последние данные с пояснением об устаревании; загрузка объявляется через status. +- Поколение запроса блокирует устаревшие результат/ошибку/finally при новом запросе, смене backend/режима и размонтировании. Физическая отмена не добавлена. Существующий URL-контекст server/tunnel сохранён. +- Прошли 9 существующих E2E контекста/таблицы и 2 новых сценария повреждённого ответа (первоначальная ошибка и ошибка после успеха, сохранение данных и успешный повтор). Lint изменённых файлов и TypeScript чистые; diff-check прошёл. Build не повторялся, последний — 9.21. Гонки запросов отдельным новым E2E пока не проверялись. +- Далее: backend VXLAN — частичный сбой опроса устройств (сейчас API не сообщает failures), сохранение последних данных по устройству и понятное состояние недоступного сервера. Полная схема отдельных туннелей и работа с реальным RouterOS остаются открытыми. Затем Firewall/Filters. Этап 9 открыт. +- Блок начат при 5% расхода пятичасовой и 1% недельной квоты. Локальный коммит без push; чужие изменения сохранены. + ## Блок 9.21 — создание и редактирование GRE (2026-09-30) - Создание принимает подтверждение ok=true/name либо объект с непустым ID и совпадением имени, serverId, удалённого адреса и enabled. Это проверка подтверждения операции, не полная схема GreTunnel. PATCH требует ok=true. Ответы соответствуют двум веткам backend; серверные правила не менялись. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 778caff8..78104bb2 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.21. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. +Статус: начат 2026-09-28, выполнены блоки 9.1–9.22. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. ## Схема и референсы принятого плана @@ -125,3 +125,7 @@ PATCH требует ok=true; замок блокирует повтор, ожи ## Блок 9.21 — формы GRE (2026-09-30) Создание проверяет ok/name либо поля подтверждённого объекта, PATCH требует ok=true. Ошибка сохраняет ввод, поля/повторы/закрытие заблокированы до ответа. Все 6 E2E прошли, остальные проверки — в журнале. Далее — VXLAN: загрузка, ошибка, повтор и URL-контекст. Полная схема GreTunnel, доступность остальных полей и реальные сетевые операции ещё требуют проверки. + +## Блок 9.22 — загрузка VXLAN (2026-09-30) + +Повреждённый контейнер не превращается в пустой список; KPI до успеха неизвестны, ошибка обновления сохраняет данные. Добавлена защита поколением запроса. Прошли 11 E2E в двух прогонах, lint/TypeScript чистые. Далее — частичные сбои опроса VXLAN на backend и сохранение данных по устройству. Полная схема туннеля, отдельные тесты гонок и реальный RouterOS ещё не проверены; последний build — 9.21. diff --git a/tests/e2e/vxlan-links.spec.ts b/tests/e2e/vxlan-links.spec.ts index e03b373a..43130224 100644 --- a/tests/e2e/vxlan-links.spec.ts +++ b/tests/e2e/vxlan-links.spec.ts @@ -1,7 +1,7 @@ import { test, expect, type Page } from "@playwright/test" async function setup(page: Page, url: string, failed = 0) { - const control = { partial: false, failed, posts: [] as { path: string; body: unknown }[] } + const control = { malformed: false, partial: false, failed, posts: [] as { path: string; body: unknown }[] } const servers = [{ id: 1, name: "Источник", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "64512", status: "online", latency: 1, os: "7", model: "CHR", wanUplinks: [] }] const tunnels = ["1", "2"].flatMap(serverId => ["*/2", "other"].map(id => ({ id, serverId, name: "vx-same", vni: Number(serverId) * 100 + (id === "other" ? 1 : 0), port: 0, dstPort: 8472, vtepIp: "192.0.2.1", remoteVteps: ["192.0.2.22"], l2mtu: 1500, arpProxy: false, macLearning: true, comment: "", enabled: true, status: "down" }))) servers.push({ ...servers[0], id: 2, name: "Второй сервер", host: "192.0.2.2" }); @@ -12,6 +12,7 @@ async function setup(page: Page, url: string, failed = 0) { const path = new URL(request.url()).pathname if (!["GET", "HEAD"].includes(request.method())) control.posts.push({ path, body: request.postDataJSON() }) if (path === "/api/vxlan" && control.failed) return route.fulfill({ status: control.failed, json: { error: "Мониторинг недоступен" } }) + if (path === "/api/vxlan" && control.malformed) return route.fulfill({ json: {} }) const json = path === "/api/auth/config" ? { required: false } : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : path === "/api/servers" ? servers @@ -109,3 +110,24 @@ for (const status of [403, 500]) { expect(control.posts).toEqual([]) }) } + +for (const initiallyFailed of [true, false]) { + test(`VXLAN: повреждённый ответ сохраняет состояние ${initiallyFailed}`, async ({ page }) => { + const control = await setup(page, "/vxlan", initiallyFailed ? 503 : 0) + const rows = page.locator("tr[data-row-id]") + if (initiallyFailed) { + await expect(page.getByText("Ошибка загрузки:", { exact: false })).toBeVisible() + await expect(page.getByLabel("Сводка VXLAN")).toContainText("—") + } else await expect(rows).toHaveCount(4) + control.failed = 0 + control.malformed = true + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByText("Ошибка загрузки: Некорректный ответ загрузки VXLAN", { exact: true })).toBeVisible() + await expect(rows).toHaveCount(initiallyFailed ? 0 : 4) + await expect(page.getByText("На опрошенных серверах нет VXLAN-интерфейсов", { exact: true })).toHaveCount(0) + control.malformed = false + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(rows).toHaveCount(4) + await expect(page.getByText("Ошибка загрузки:", { exact: false })).toHaveCount(0) + }) +}