diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 22553602..3f26094f 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -530,8 +530,6 @@ export default function IpsecPage() { setPeerOpen(false) setEditingPeer(null) await loadLive() - } catch (e) { - toast.error("Ошибка обновления peer", { description: e instanceof Error ? e.message : String(e) }) } finally { setBusy(false) } diff --git a/components/ipsec/ipsec-peer-sheet.tsx b/components/ipsec/ipsec-peer-sheet.tsx index 736cc8f5..651fb34f 100644 --- a/components/ipsec/ipsec-peer-sheet.tsx +++ b/components/ipsec/ipsec-peer-sheet.tsx @@ -1,9 +1,10 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import type { IpsecPeerDto } from "@mmapp/contracts/ipsec" -import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" +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, @@ -49,11 +50,19 @@ function IpsecPeerSheet({ onSubmit: (form: IpsecPeerFormState) => void | Promise }) { const [form, setForm] = useState(defaultIpsecPeerForm) + const lock = useRef(false) + const opened = useRef(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const pending = busy || submitting const set = (k: K, v: IpsecPeerFormState[K]) => setForm((f) => ({ ...f, [k]: v })) useEffect(() => { - if (!open || !editing) return + if (!open) { opened.current = false; return } + if (!editing || opened.current) return + opened.current = true + queueMicrotask(() => setError(null)) queueMicrotask(() => setForm({ name: editing.name, address: editing.address ?? "", @@ -67,9 +76,19 @@ function IpsecPeerSheet({ const canSubmit = useMemo(() => form.name.trim().length > 0, [form.name]) + const submit = async () => { + if (!canSubmit || busy || lock.current) return + lock.current = true + setSubmitting(true) + setError(null) + try { await onSubmit(form) } + catch (error) { setError(error instanceof Error ? error.message : "Не удалось сохранить peer") } + finally { lock.current = false; setSubmitting(false) } + } + return ( - - + { if (!lock.current && !busy) onOpenChange(value) }}> + {editing ? `Peer «${editing.name}»` : "Peer"} @@ -80,18 +99,19 @@ function IpsecPeerSheet({
+
Основные - set("name", e.target.value)} /> + set("name", e.target.value)} /> - set("address", e.target.value)} /> + set("address", e.target.value)} /> set("profile", e.target.value)} /> + set("profile", e.target.value)} />
@@ -120,25 +140,28 @@ function IpsecPeerSheet({ Состояние

Passive

- set("passive", v)} /> + set("passive", v)} />

Отключён

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

Ожидание результата сохранения…

} + {error &&

{error}

} - }> + }> Отмена
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 2fea7b70..9a311905 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.17 — история, формы, экспорт и удаление IPsec); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. +По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.18 — история, формы, экспорт, удаление и peer 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 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. Далее — редактирование peer IPsec, затем 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, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. Далее — операции туннелей GRE, затем 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 cab1b268..c1a2e992 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.17 — история, формы, экспорт и удаление IPsec | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.18 — история, формы, экспорт, удаление и peer IPsec | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.18 — редактирование peer IPsec (2026-09-29) + +- PATCH peer требует ok=true. Ошибка передаётся в форму, сохраняет введённые поля и не закрывает панель. Синхронный замок предотвращает повтор; поля, переключатели и закрытие заблокированы до ответа, ожидание объявляется через status. Начальные значения задаются один раз на открытие; добавлены доступные имена. +- Новый E2E прошёл: точный RouterOS ID, двойное нажатие, задержка, Escape/внешний клик, 403, пустой ответ, ok=false, успешный повтор и сброс ошибки при новом открытии. Lint изменённых файлов и TypeScript прошли; diff-check чистый. Build не повторялся, последний — 9.16. +- Далее: GRE — создание/изменение/удаление туннеля, проверка подтверждения API, сохранение формы и повторы; затем VXLAN/Firewall/Filters по карте. IPsec не объявляется полностью принятым: загрузка данных, реальные сертификаты/RouterOS/JWT и сквозная приёмка ещё требуют проверки. +- Блок начат при 88% расхода пятичасовой квоты по прямому продолжению пользователя и ранее разрешённой работе до конца лимита. Завершён самостоятельный небольшой блок; локальный коммит без push, чужие изменения сохранены. + ## Блок 9.17 — удаление объектов IPsec (2026-09-29) - Все четыре API удаления (клиент, сервер, peer, сертификат) требуют ok=true. Пустой ответ и ok=false не вызывают успех и не закрывают подтверждение. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 8063bab4..e941bcca 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.17. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. +Статус: начат 2026-09-28, выполнены блоки 9.1–9.18. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. ## Схема и референсы принятого плана @@ -109,3 +109,7 @@ IPsec/GRE/Firewall/Filters/рекурсивные маршруты требую ## Блок 9.17 — удаление IPsec (2026-09-29) Удаление клиента/сервера/peer/сертификата требует ok=true; диалог сохраняется при ошибке, блокирует повтор и закрытие до ответа. Принудительное удаление после 409 перенесено из toast в тот же диалог с общей защитой. Все 4 E2E прошли; lint/TypeScript — в журнале. Далее — редактирование peer IPsec. Реальные сетевые последствия не проверены, последний build — 9.16. + +## Блок 9.18 — peer IPsec (2026-09-29) + +Сохранение требует ok=true; ошибка остаётся в форме вместе с вводом. Поля/переключатели, повторы и закрытие блокируются до ответа. Новый E2E, lint и TypeScript прошли. Далее — создание/изменение/удаление GRE. Загрузка данных IPsec и реальная сетевая приёмка остаются открытыми; последний build — 9.16. diff --git a/shared/api/ipsec.ts b/shared/api/ipsec.ts index 7c3d5c2f..c141df3d 100644 --- a/shared/api/ipsec.ts +++ b/shared/api/ipsec.ts @@ -157,11 +157,13 @@ export async function patchIpsecPeer( peerId: string, patch: IpsecPeerPatch, ): Promise<{ ok: boolean }> { - return requestJson<{ ok: boolean }>( + const result = await requestJson<{ ok: boolean }>( baseUrl, `/api/ipsec/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(peerId)}`, { method: "PATCH", body: JSON.stringify(patch) }, ) + if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение peer") + return result } export async function deleteIpsecPeer( diff --git a/tests/e2e/ipsec-peer.spec.ts b/tests/e2e/ipsec-peer.spec.ts new file mode 100644 index 00000000..0fd2cb8c --- /dev/null +++ b/tests/e2e/ipsec-peer.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from "@playwright/test" + +test("IPsec: peer сохраняет поля и ждёт подтверждения API", 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 (route.request().method() === "PATCH") { + attempts++ + expect(decodeURIComponent(path)).toBe("/api/ipsec/peers/1/*P/1") + expect(route.request().postDataJSON()).toMatchObject({ name: "updated", address: "192.0.2.0/24", profile: "test-profile", passive: true, disabled: true }) + 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: { ok: false } }) + return route.fulfill({ json: { ok: true } }) + } + return route.fulfill({ json: path === "/api/auth/config" ? { required: false } + : path === "/api/ipsec" ? { servers: [{ serverId: "1", serverName: "Роутер", initialized: true, ike2Ready: true, natRuleManaged: true, clientsTotal: 0, clientsOnline: 0, peers: [{ id: "p", rosId: "*P/1", serverId: "1", serverName: "Роутер", name: "peer-test", managed: true, disabled: false, passive: false }] }], 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("tab", { name: "Сервер", exact: true }).click() + await page.getByRole("button", { name: "Изменить peer", exact: true }).click() + const sheet = page.getByRole("dialog") + await sheet.getByLabel("Имя", { exact: true }).fill("updated") + await sheet.getByLabel("Адрес", { exact: true }).fill("192.0.2.0/24") + await sheet.getByLabel("Profile", { exact: true }).fill("test-profile") + await sheet.getByRole("switch", { name: "Passive" }).click() + await sheet.getByRole("switch", { name: "Отключён" }).click() + const submit = sheet.getByRole("button", { name: "Сохранить", exact: true }) + 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("Имя", { exact: true })).toBeDisabled() + await expect(sheet.getByRole("switch", { name: "Passive" })).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")).toContainText("Редактирование запрещено тест") + await expect(sheet.getByLabel("Имя", { exact: true })).toHaveValue("updated") + await expect(sheet.getByLabel("Profile", { exact: true })).toHaveValue("test-profile") + for (let attempt = 2; attempt <= 3; attempt++) { + await submit.click() + await expect.poll(() => attempts).toBe(attempt) + await expect(sheet.getByRole("alert")).toContainText("Сервер не подтвердил сохранение peer") + await expect(page.getByText("Peer обновлён", { exact: true })).toHaveCount(0) + } + await submit.click() + await expect(sheet).toHaveCount(0) + expect(attempts).toBe(4) + await page.getByRole("button", { name: "Изменить peer", exact: true }).click() + await expect(sheet.getByRole("alert")).toHaveCount(0) + await expect(sheet.getByLabel("Имя", { exact: true })).toHaveValue("peer-test") +})