diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 1a0ad4f9..0f591291 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -370,10 +370,6 @@ export default function IpsecPage() { toast.success("IKEv2-сервер инициализирован") setInitOpen(false) await loadLive() - } catch (e) { - toast.error("Ошибка инициализации", { - description: e instanceof Error ? e.message : String(e), - }) } finally { setBusy(false) } diff --git a/components/ipsec/ipsec-init-sheet.tsx b/components/ipsec/ipsec-init-sheet.tsx index efd8fc4d..497b658b 100644 --- a/components/ipsec/ipsec-init-sheet.tsx +++ b/components/ipsec/ipsec-init-sheet.tsx @@ -1,8 +1,9 @@ "use client" -import { useEffect, useMemo, useState } from "react" -import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" +import { useEffect, useMemo, useRef, useState } from "react" +import { FormField, SectionTitle } from "@/components/form-kit" import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" import { Input } from "@/components/ui/input" import { Sheet, SheetContent, SheetHeader, SheetTitle, @@ -45,31 +46,58 @@ function IpsecInitSheet({ onSubmit: (form: IpsecInitFormState) => void | Promise }) { const [form, setForm] = useState(defaultIpsecInitForm) + const submittingRef = useRef(false) + const openedRef = useRef(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const pending = busy || submitting const set = (k: K, v: IpsecInitFormState[K]) => setForm((f) => ({ ...f, [k]: v })) useEffect(() => { - if (!open) return + if (!open) { openedRef.current = false; return } + if (openedRef.current) return + openedRef.current = true const server = servers.find((s) => s.id === (defaultServerId ?? "")) - queueMicrotask(() => setForm({ - ...defaultIpsecInitForm(), - serverId: defaultServerId ?? "", - serverEndpoint: server?.host ?? "", - })) + queueMicrotask(() => { + setError(null) + setForm({ + ...defaultIpsecInitForm(), + serverId: defaultServerId ?? "", + serverEndpoint: server?.host ?? "", + }) + }) }, [open, defaultServerId, servers]) const canSubmit = useMemo(() => { return Boolean( form.serverId && form.serverEndpoint.trim() && - /^(\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}|[\w.-]+\.[a-z]{2,})$/i.test(form.serverEndpoint.trim()) && + (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(form.serverEndpoint.trim()) + ? form.serverEndpoint.trim().split(".").every(part => Number(part) <= 255) + : /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(form.serverEndpoint.trim())) && /^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/.test(form.poolCidr.trim()), ) }, [form]) + const submit = async () => { + if (!canSubmit || busy || submittingRef.current) return + submittingRef.current = true + setSubmitting(true) + setError(null) + try { + await onSubmit(form) + } catch (error) { + setError(error instanceof Error ? error.message : "Не удалось инициализировать сервер") + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + return ( - - + { if (!submittingRef.current && !busy) onOpenChange(value) }}> + Инициализация IKEv2-сервера @@ -87,10 +115,11 @@ function IpsecInitSheet({ -
+
Основные set("serverEndpoint", e.target.value)} /> @@ -119,6 +149,7 @@ function IpsecInitSheet({ set("poolCidr", e.target.value)} /> @@ -126,6 +157,7 @@ function IpsecInitSheet({ set("dns", e.target.value)} /> @@ -137,21 +169,23 @@ function IpsecInitSheet({ Managed srcnat masquerade для подсети клиентов (out-interface-list WAN)

- set("createNatRule", v)} /> + set("createNatRule", v)} /> - + + {pending &&

Ожидание результата инициализации…

} + {error &&

{error}

} - }> + }> Отмена
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index d263e11f..df263fab 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.13 — история всех шести разделов и восстановление); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. +По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.14 — история, восстановление и инициализация 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 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. Далее — инициализация IPsec: подтверждение API, сохранение формы и защита повторной отправки; затем пользователи/сертификаты и GRE/VXLAN/Firewall/Filters. +1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. Далее — создание/редактирование пользователей 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 7ce31e6f..b851978c 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -25,12 +25,21 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.13 — история всех шести разделов и восстановление | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.14 — история, восстановление и инициализация IPsec | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.14 — инициализация IPsec (2026-09-29) + +- Успех требует валидной сводки по contracts, совпадения serverId и initialized=true. Пустой ответ, неинициализированный сервер и чужое устройство не закрывают форму и не показывают успех. Backend возвращает сводку после применения; правила сетевой операции не изменялись. +- Форма сохраняет ввод и показывает ошибку внутри панели. Синхронный замок предотвращает повтор; до ответа заблокированы поля, NAT, отмена, Escape и внешний клик. Ожидание объявляется через status. Инициализация полей выполняется один раз на открытие, чтобы обновление списка серверов не стирало ввод. +- Исправлен ввод IPv4 без CIDR вместо ошибочного требования маски; домены сохранены, IPv4 с октетом больше 255 отклоняется. Поля и переключатель получили доступные имена. Полная валидация подсети остаётся на backend. +- Прошли новый E2E и 3 регрессионных теста таблиц IPsec/GRE, lint всех изменённых файлов и TypeScript; diff-check чистый. Первоначальный тест исправлен: перед кнопкой инициализации требуется вкладка «Сервер». Build не повторялся; последний успешный — блок 9.13. +- Следующий шаг: создание/редактирование пользователя IPsec — проверка контракта результата, сохранение формы, защита повторов; затем сертификаты и остальные разделы карты. Реальные RouterOS/JWT и транзакционность составной инициализации не проверены. Этап 9 открыт. +- Использовано 41% пятичасовой и 84% недельной квоты. Локальный коммит без push; изменения пользователя в skill/config/AGENTS сохранены. + ## Блок 9.13 — общая проверка и загрузка истории (2026-09-29) - GRE, Firewall, Filters и рекурсивные маршруты подключены к ошибке/повтору общей панели. Ошибка не обнуляет сохранённые ревизии и не маскируется пустым списком. Поколение запроса защищает результат, ошибку и finally после закрытия, смены контекста и размонтирования; физическая отмена запросов не добавлялась. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 90f24465..8c1ac02a 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.13. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. +Статус: начат 2026-09-28, выполнены блоки 9.1–9.14. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. ## Схема и референсы принятого плана @@ -93,3 +93,7 @@ IPsec/GRE/Firewall/Filters/рекурсивные маршруты требую ## Блок 9.13 — история всех разделов (2026-09-29) Ошибка/повтор и защита поздних ответов подключены к GRE/Firewall/Filters/рекурсивным маршрутам. Общая схема проверяет записи, сервер и раздел во всех шести потребителях, включая IPsec. Все 36 E2E истории/WireGuard и TypeScript прошли; исходный lint страниц не изменился, новые общие файлы и тест чистые. Результат build — в журнале. Далее — форма инициализации IPsec (handleInit): подтверждение, сохранение ввода и повторные нажатия. Реальные устройства и payload восстановления rules/routes остаются вне проверки этого блока. + +## Блок 9.14 — инициализация IPsec (2026-09-29) + +Проверяются схема сводки, serverId и initialized=true. Ошибка остаётся в форме с введёнными значениями; замок блокирует повтор, редактирование и закрытие во время запроса. Исправлен ввод IPv4, добавлены доступные имена. Новый E2E и 3 теста таблиц прошли, lint/TypeScript чистые. Далее — создание/редактирование пользователей IPsec. Реальные устройства и транзакционность сетевой операции не проверены; последний build — 9.13. diff --git a/shared/api/ipsec.ts b/shared/api/ipsec.ts index 1584c652..3d9db4aa 100644 --- a/shared/api/ipsec.ts +++ b/shared/api/ipsec.ts @@ -1,3 +1,4 @@ +import { ipsecServerSummaryDtoSchema } from "@mmapp/contracts/ipsec" import { requestJson } from "@/shared/api/http-client" import type { IpsecCertBundle, @@ -24,10 +25,15 @@ export async function initIpsecServer( baseUrl: string, body: IpsecInitRequest, ): Promise { - return requestJson(baseUrl, "/api/ipsec/server/init", { + const response = await requestJson(baseUrl, "/api/ipsec/server/init", { method: "POST", body: JSON.stringify(body), }) + const result = ipsecServerSummaryDtoSchema.safeParse(response) + if (!result.success || result.data.serverId !== body.serverId || !result.data.initialized) { + throw new Error("Сервер не подтвердил инициализацию IKEv2") + } + return result.data } export async function deleteIpsecServer( diff --git a/tests/e2e/ipsec-init.spec.ts b/tests/e2e/ipsec-init.spec.ts new file mode 100644 index 00000000..5c368dd2 --- /dev/null +++ b/tests/e2e/ipsec-init.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "@playwright/test" + +test("IPsec: инициализация сохраняет ввод, блокирует повтор и требует подтверждения", async ({ page }) => { + const summary = { serverId: "1", serverName: "IPsec роутер", initialized: false, ike2Ready: false, natRuleManaged: false, clientsTotal: 0, clientsOnline: 0, peers: [] } + 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/server/init") { + attempts++ + expect(route.request().postDataJSON()).toMatchObject({ serverId: "1", serverEndpoint: "192.0.2.1", poolCidr: "10.78.0.0/24", dns: "1.1.1.1", createNatRule: false }) + if (attempts === 1) { + await gate + return route.fulfill({ status: 403, json: { error: "Инициализация запрещена тест" } }) + } + if (attempts === 2) return route.fulfill({ json: {} }) + if (attempts === 3) return route.fulfill({ json: summary }) + if (attempts === 4) return route.fulfill({ json: { ...summary, initialized: true, serverId: "2" } }) + return route.fulfill({ status: 201, json: { ...summary, initialized: true, ike2Ready: true } }) + } + return route.fulfill({ json: path === "/api/auth/config" ? { required: false } + : path === "/api/ipsec" ? { servers: [summary], clients: [] } + : path === "/api/servers" ? [{ id: 1, name: "IPsec роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} }) + }) + await page.goto("/ipsec") + await page.getByRole("tab", { name: "Сервер", exact: true }).click() + await page.getByRole("button", { name: "Инициализировать", exact: true }).click() + const sheet = page.getByRole("dialog", { name: "Инициализация IKEv2-сервера" }) + const submit = sheet.getByRole("button", { name: "Инициализировать", exact: true }) + await expect(sheet.getByLabel("Адрес сервера (CN/SAN)")).toHaveValue("192.0.2.1") + await expect(submit).toBeEnabled() + await sheet.getByLabel("Адрес сервера (CN/SAN)").fill("999.0.0.1") + await expect(submit).toBeDisabled() + await sheet.getByLabel("Адрес сервера (CN/SAN)").fill("vpn.example.com") + await expect(submit).toBeEnabled() + await sheet.getByLabel("Адрес сервера (CN/SAN)").fill("192.0.2.1") + await sheet.getByLabel("Подсеть клиентов").fill("10.78.0.0/24") + await sheet.getByLabel("DNS для клиентов").fill("1.1.1.1") + await sheet.getByRole("switch", { name: "Интернет клиентам (NAT)" }).click() + await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + await expect.poll(() => attempts).toBe(1) + await expect(sheet.getByRole("status")).toContainText("Ожидание результата") + await expect(sheet.getByLabel("Подсеть клиентов")).toBeDisabled() + await expect(sheet.getByLabel("Сервер", { exact: true })).toBeDisabled() + await expect(sheet.getByRole("switch")).toBeDisabled() + await expect(sheet.getByRole("button", { name: "Отмена" })).toBeDisabled() + await page.keyboard.press("Escape") + await page.mouse.click(10, 10) + await expect(sheet).toBeVisible() + release!() + await expect(sheet.getByRole("alert").filter({ hasText: "Инициализация запрещена тест" })).toBeVisible() + await expect(sheet.getByLabel("Подсеть клиентов")).toHaveValue("10.78.0.0/24") + await expect(sheet.getByLabel("DNS для клиентов")).toHaveValue("1.1.1.1") + for (let attempt = 2; attempt <= 4; attempt++) { + await submit.click() + await expect.poll(() => attempts).toBe(attempt) + await expect(sheet.getByRole("alert").filter({ hasText: "Сервер не подтвердил инициализацию IKEv2" })).toBeVisible() + await expect(page.getByText("IKEv2-сервер инициализирован", { exact: true })).toHaveCount(0) + } + await submit.click() + await expect(sheet).toHaveCount(0) + expect(attempts).toBe(5) + await page.getByRole("button", { name: "Инициализировать", exact: true }).click() + await expect(sheet.getByLabel("Подсеть клиентов")).toHaveValue("10.77.0.0/24") + await expect(sheet.getByText("Сервер не подтвердил инициализацию IKEv2", { exact: true })).toHaveCount(0) +})