diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index ff94327a..4b30dda2 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { PageHeader } from "@/components/page-header" import { servers as mockServers } from "@/lib/data" import type { Server } from "@/lib/data" @@ -190,6 +190,8 @@ export default function IpsecPage() { const [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) const [historyLoading, setHistoryLoading] = useState(false) + const [historyError, setHistoryError] = useState(null) + const historyEpoch = useRef(0) const [historyRestoring, setHistoryRestoring] = useState(false) const loadLive = useCallback(async () => { @@ -245,20 +247,26 @@ export default function IpsecPage() { const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId + useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive]) + const loadRevisions = useCallback(async () => { if (!isLive || !historyServerId) return + const epoch = ++historyEpoch.current setHistoryLoading(true) + setHistoryError(null) try { const res = await requestJson<{ revisions: ConfigRevisionDto[] }>( backendUrl, `/api/ipsec/revisions?serverId=${encodeURIComponent(historyServerId)}`, ) + if (epoch !== historyEpoch.current) return + if (!Array.isArray(res?.revisions)) throw new Error("Некорректный ответ истории IPsec") setRevisions(res.revisions) } catch (err) { - toast.error("Не удалось загрузить историю", { description: String(err) }) - setRevisions([]) + if (epoch !== historyEpoch.current) return + setHistoryError(err instanceof Error ? err.message : "Не удалось загрузить историю") } finally { - setHistoryLoading(false) + if (epoch === historyEpoch.current) setHistoryLoading(false) } }, [isLive, historyServerId, backendUrl]) @@ -884,7 +892,9 @@ export default function IpsecPage() { { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }} + loadError={historyError} + onRetry={() => void loadRevisions()} revisions={revisions} loading={historyLoading} restoring={historyRestoring} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 19501d88..14776065 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.11 — история и подтверждение восстановления); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. +По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.12 — история WireGuard/IPsec и восстановление); этапы 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 подтверждение восстановления проверено у остальных пяти потребителей. Далее — их состояния загрузки истории: ошибка, повтор и поздние ответы; затем IPsec/GRE/VXLAN/Firewall/Filters. +1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. Далее — история GRE/Firewall/Filters/рекурсивных маршрутов и общая валидация ревизий; затем IPsec/GRE/VXLAN/Firewall/Filters. 2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения. 3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 4fd33728..fd838359 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.11 — история и подтверждение восстановления | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.12 — история WireGuard/IPsec и восстановление | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.12 — ошибка и повтор истории IPsec (2026-09-29) + +- IPsec подключён к loadError/onRetry общего диалога. Ошибка не обнуляет историю и не показывает пустое состояние; прежние ревизии скрыты до успешного повтора. Ответ без массива revisions отклоняется. +- Поколение запроса и инвалидирование при закрытии, смене устройства/backend/режима или размонтировании блокируют устаревшие результат/ошибку/finally. Сетевые запросы физически не отменяются. +- Все 7 E2E истории прошли: прежние 6 сценариев восстановления и новый IPsec (403, повтор с задержкой, закрытие/открытие, повреждённый ответ, успешный пустой список, поздняя ошибка). Lint/TypeScript проверены перед коммитом; diff-check чистый. Build не повторялся; последний — 9.9. +- Ограничение: здесь проверен только контейнер revisions, полная схема отдельных ревизий и принадлежность серверу ещё не валидируются, в отличие от WireGuard. Реальные RouterOS/JWT не проверены. Далее — история GRE/Firewall/Filters/рекурсивных маршрутов и общая валидация ревизий. Этап 9 открыт. Блок начат при 87% расхода окна по прямому разрешению работать до конца лимита; локальный коммит без push. + ## Блок 9.11 — подтверждение восстановления остальных разделов (2026-09-29) - IPsec, GRE, Firewall, Filters и рекурсивные маршруты проверяют ok=true до изменения таблиц, обновления данных и сообщения успеха. Контракты всех пяти backend-маршрутов проверены по коду: они возвращают такое подтверждение. WireGuard уже использовал эту проверку. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index e668a0da..0bac17f5 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.11. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. +Статус: начат 2026-09-28, выполнены блоки 9.1–9.12. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. ## Схема и референсы принятого плана @@ -85,3 +85,7 @@ ## Блок 9.11 — подтверждение восстановления остальных разделов (2026-09-29) IPsec/GRE/Firewall/Filters/рекурсивные маршруты требуют ok=true до применения ответа в UI; WireGuard уже проверял подтверждение. 6 расширенных сценариев истории прошли (403, ok=false, пустой ответ, успех), как и TypeScript/lint теста/diff-check. Исходные ошибки lint страниц не изменились. Далее — ошибка/повтор/поздние ответы загрузки истории этих пяти разделов. Полная валидация rules/routes и реальная операция RouterOS остаются вне этого блока. + +## Блок 9.12 — загрузка истории IPsec (2026-09-29) + +Ошибка и повтор подключены к общей панели; поздние ответы закрытого/сменившего контекст запроса игнорируются. Проверяется наличие массива revisions, без полной валидации записей. Все 7 E2E истории прошли. Далее — GRE/Firewall/Filters/рекурсивные маршруты и общая схема ревизий. Реальные устройства не проверены. diff --git a/tests/e2e/config-history.spec.ts b/tests/e2e/config-history.spec.ts index 4922e58b..ec275416 100644 --- a/tests/e2e/config-history.spec.ts +++ b/tests/e2e/config-history.spec.ts @@ -67,3 +67,48 @@ for (const section of ["wireguard", "ipsec", "gre", "firewall", "filters", "recu expect(errors.filter(error => !error.includes("403 (Forbidden)"))).toEqual([]) }) } + +test("История IPsec: ошибка, повтор и поздний ответ закрытой панели", async ({ page }) => { + let attempts = 0 + let release: (() => void) | undefined + const gate = new Promise(resolve => { release = resolve }) + await page.route("**/health", route => route.fulfill({ json: { ok: true } })) + await page.route("**/api/**", async route => { + const path = new URL(route.request().url()).pathname + if (path === "/api/ipsec/revisions") { + attempts++ + if (attempts === 1) return route.fulfill({ status: 403, json: { error: "История недоступна тест" } }) + if (attempts === 2) { + await gate + return route.fulfill({ status: 500, json: { error: "Устаревшая ошибка тест" } }) + } + if (attempts === 3) return route.fulfill({ json: {} }) + return route.fulfill({ json: { revisions: [] } }) + } + return route.fulfill({ json: path === "/api/auth/config" ? { required: false } + : path === "/api/ipsec" ? { servers: [], clients: [] } + : path === "/api/servers" ? [{ id: 1, name: "История роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} }) + }) + await page.goto("/ipsec") + await page.getByRole("option").filter({ hasText: "История роутер" }).click() + const open = page.getByRole("button", { name: "История", exact: true }) + const sheet = page.getByRole("dialog", { name: "История конфигураций IPsec" }) + await open.click() + await expect(sheet.getByRole("alert")).toContainText("История недоступна тест") + await expect(sheet.getByText("Истории пока нет", { exact: true })).toHaveCount(0) + await sheet.getByRole("button", { name: "Повторить загрузку" }).click() + await expect.poll(() => attempts).toBe(2) + await expect(sheet.getByRole("status")).toContainText("Загрузка истории") + await page.keyboard.press("Escape") + await expect(sheet).toHaveCount(0) + await open.click() + await expect(sheet.getByRole("alert")).toContainText("Некорректный ответ истории IPsec") + await sheet.getByRole("button", { name: "Повторить загрузку" }).click() + await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible() + const response = page.waitForResponse(r => r.url().includes("/api/ipsec/revisions?")) + release!() + await (await response).finished() + await page.waitForTimeout(150) + await expect(sheet.getByRole("alert")).toHaveCount(0) + await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible() +})