diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 87ca624f..19b18975 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -183,6 +183,9 @@ function IpsecPage() { const [dataError, setDataError] = useState(null) const [inventoryError, setInventoryError] = useState(null) const [hasLoaded, setHasLoaded] = useState(false) + const mounted = useRef(true) + const operationLock = useRef(false) + useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, []) const readEpoch = useRef(0) useEffect(() => () => { readEpoch.current += 1 }, []) const [busy, setBusy] = useState(false) @@ -215,8 +218,8 @@ function IpsecPage() { const historyEpoch = useRef(0) const [historyRestoring, setHistoryRestoring] = useState(false) - const loadLive = useCallback(async () => { - if (!isLive) return + const loadLive = useCallback(async (afterOperation = false) => { + if (!isLive || !mounted.current || (operationLock.current && !afterOperation)) return const epoch = ++readEpoch.current setLoading(true) const [ipsecResult, inventoryResult] = await Promise.allSettled([ @@ -262,7 +265,7 @@ function IpsecPage() { useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive]) const loadRevisions = useCallback(async () => { - if (!isLive || !historyServerId) return + if (!isLive || !historyServerId || !mounted.current) return const epoch = ++historyEpoch.current setHistoryLoading(true) setHistoryError(null) @@ -281,21 +284,24 @@ function IpsecPage() { } }, [isLive, historyServerId, backendUrl]) - const restoreRevision = useCallback(async (id: string) => { + const restoreRevision = async (id: string) => { if (!isLive || !historyServerId) return + beginOperation(historyServerId) setHistoryRestoring(true) try { const res = await restoreIpsecRevision(backendUrl, id, historyServerId) + if (!mounted.current) return if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление") toast.success("Версия применена на роутер") - await loadLive() + await loadLive(true) await loadRevisions() } catch (err) { throw err } finally { - setHistoryRestoring(false) + endOperation() + if (mounted.current) setHistoryRestoring(false) } - }, [isLive, historyServerId, backendUrl, loadLive, loadRevisions]) + } const failedServerIds = new Set(failures.map(f => f.serverId)) const scopeUnknown = isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId))) @@ -303,7 +309,18 @@ function IpsecPage() { const canManage = (serverId: string) => isLive && hasLoaded && !dataError && !inventoryError && !loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId) const assertDeviceReady = (serverId: string) => { - if (!canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec") + if (operationLock.current || !canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec") + } + const beginOperation = (serverId: string) => { + assertDeviceReady(serverId) + operationLock.current = true + readEpoch.current += 1 + setLoading(false) + setBusy(true) + } + const endOperation = () => { + operationLock.current = false + if (mounted.current) setBusy(false) } const hasReadyDevice = displayServers.some(s => canManage(s.id)) @@ -377,8 +394,7 @@ function IpsecPage() { }, [displayServers, displayClients]) const handleInit = async (form: IpsecInitFormState) => { - assertDeviceReady(form.serverId) - setBusy(true) + beginOperation(form.serverId) try { await initIpsecServer(backendUrl, { serverId: form.serverId, @@ -390,17 +406,17 @@ function IpsecPage() { clientDaysValid: 1825, createNatRule: form.createNatRule, }) + if (!mounted.current) return toast.success("IKEv2-сервер инициализирован") setInitOpen(false) - await loadLive() + await loadLive(true) } finally { - setBusy(false) + endOperation() } } const handleCreateUser = async (form: IpsecUserFormState) => { - assertDeviceReady(form.serverId) - setBusy(true) + beginOperation(form.serverId) try { const res = await createIpsecUser(backendUrl, { serverId: form.serverId, @@ -412,10 +428,12 @@ function IpsecPage() { staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : undefined, passphrase: form.passphrase.trim() || undefined, }) + if (!mounted.current) return toast.success(`Клиент «${form.name.trim()}» создан`) setUserOpen(false) setEditing(null) - await loadLive() + await loadLive(true) + if (!mounted.current) return if (res.bundle) { certEpoch.current += 1 certPending.current = false @@ -427,36 +445,37 @@ function IpsecPage() { setCertOpen(true) } } finally { - setBusy(false) + endOperation() } } const handleEditUser = async (form: IpsecUserFormState) => { if (!editing) return - assertDeviceReady(editing.serverId) - setBusy(true) + beginOperation(editing.serverId) try { await patchIpsecUser(backendUrl, editing.serverId, editing.rosId, { name: form.name.trim() !== editing.name ? form.name.trim() : undefined, staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : null, psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined, }) + if (!mounted.current) return toast.success("Клиент обновлён") setUserOpen(false) setEditing(null) - await loadLive() + await loadLive(true) } finally { - setBusy(false) + endOperation() } } - const loadCert = async (request: () => Promise) => { + const loadCert = async (serverId: string, request: () => Promise) => { if (certPending.current) return certPending.current = true const epoch = ++certEpoch.current setCertBusy(true) setCertError(null) try { + assertDeviceReady(serverId) const bundle = await request() if (epoch === certEpoch.current) setCertBundle(bundle) } catch (error) { @@ -475,7 +494,7 @@ function IpsecPage() { setCertBundle(null) setCertOpen(true) const passphrase = crypto.randomUUID() - await loadCert(() => byName + await loadCert(client.serverId, () => byName ? exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase) : exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)) } @@ -487,12 +506,12 @@ function IpsecPage() { setCertClient(null) setCertBundle(null) setCertOpen(true) - await loadCert(() => exportIpsecCertByName(backendUrl, serverId, cert.name, crypto.randomUUID())) + await loadCert(serverId, () => exportIpsecCertByName(backendUrl, serverId, cert.name, crypto.randomUUID())) } const reexportCert = async (passphrase: string) => { if (!certClient && !certByName) return - await loadCert(() => certByName + await loadCert(certByName?.serverId ?? certClient!.serverId, () => certByName ? exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase) : exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)) } @@ -501,9 +520,9 @@ function IpsecPage() { if (!pendingDelete || deleteLock.current || (force && !forceAllowed)) return const target = pendingDelete const serverId = target.kind === "user" ? target.client.serverId : target.kind === "server" ? target.summary.serverId : target.serverId - if (!canManage(serverId)) { setDeleteError("Повторите опрос IPsec перед удалением"); return } + if (operationLock.current || !canManage(serverId)) { setDeleteError("Повторите опрос IPsec перед удалением"); return } + beginOperation(serverId) deleteLock.current = true - setBusy(true) setDeleteError(null) try { if (target.kind === "user") { @@ -515,16 +534,18 @@ function IpsecPage() { } else { await deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force }) } + if (!mounted.current) return toast.success("Удалено") setPendingDelete(null) setForceAllowed(false) - await loadLive() + await loadLive(true) } catch (error) { + if (!mounted.current) return setDeleteError(error instanceof Error ? error.message : "Не удалось удалить") setForceAllowed(error instanceof ApiClientError && error.status === 409 && (target.kind === "peer" || target.kind === "cert")) } finally { deleteLock.current = false - setBusy(false) + endOperation() } } @@ -535,8 +556,7 @@ function IpsecPage() { const handleEditPeer = async (form: IpsecPeerFormState) => { if (!editingPeer) return - assertDeviceReady(editingPeer.serverId) - setBusy(true) + beginOperation(editingPeer.serverId) try { await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, { name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined, @@ -547,12 +567,13 @@ function IpsecPage() { profile: form.profile.trim() || undefined, disabled: form.disabled, }) + if (!mounted.current) return toast.success("Peer обновлён") setPeerOpen(false) setEditingPeer(null) - await loadLive() + await loadLive(true) } finally { - setBusy(false) + endOperation() } } @@ -673,7 +694,7 @@ function IpsecPage() { {dataError ? "Не удалось загрузить IPsec" : "Не удалось загрузить устройства"} {dataError || inventoryError}. {hasLoaded ? "Показаны последние полученные данные; их актуальность не подтверждена." : "Данные пока неизвестны."} - + ) : null} @@ -850,7 +871,7 @@ function IpsecPage() { canManage(s.id) || busy)} busy={busy} defaultServerId={sheetServerId} onSubmit={handleInit} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 22bda077..66f6b7e2 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.49 — IPsec/GRE/VXLAN, Firewall и операции Filters | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.50 — IPsec/GRE/VXLAN, Firewall и операции Filters | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.50 — замок и контекст операций IPsec (2026-09-30) + +- Общий синхронный operationLock охватывает инициализацию, создание/редактирование клиентов/peer, удаление и восстановление до последующего чтения. Начало записи инвалидирует предыдущий опрос; внешний refresh при записи не отправляется. Восстановление и экспорт проверяют readiness устройства, ошибки остаются в своих панелях. +- Mounted-guard отсекает поздние успехи записи после смены источника: нет старого toast, повторного чтения или открытия сертификата. Ошибка удаления после размонтирования также игнорируется. Уже отправленная операция RouterOS не отменяется и может завершиться на прежнем backend — отмена UI не является отменой сетевой операции. +- Успешное удаление закрывает подтверждение даже при отказе последующего опроса; таблица сохраняет последние строки с явной ошибкой чтения и отключёнными действиями до повтора. Проверки не выдают это за неуспешное удаление. +- 30 E2E прошли: 6 отдельных тестов backend/mode контекста, 23 регрессии IPsec/истории (включая GRE smoke) и новый сценарий замка/ошибки чтения. TypeScript и lint чистые. Отдельная конфигурация ipsec-context исключена из обычной и использует порт 3101 последовательно. +- Далее — клиентская свежесть IPsec и профильные ПК/телефон экраны; затем оставшиеся VXLAN/GRE. Последние build frontend — 9.47, backend — 9.44. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push. + ## Блок 9.49 — readiness действий IPsec (2026-09-30) - Кнопки клиентов и серверов используют readiness конкретного serverId: требуется успешное чтение, доступный inventory, включённое устройство, отсутствие его failure и текущей загрузки/операции. Сохранённые строки не превращаются в разрешение редактировать/удалять. Успешное устройство остаётся доступным при частичном отказе другого. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 736b655b..3fa7080e 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -256,3 +256,11 @@ Live-копирование читает актуальный целевой rul - Формы создания получают только доступные устройства, уже выполняемый запрос сохраняет список. Submit и удаление дополнительно проверяют readiness перед API. Первоначальный/полный отказ блокирует создание. Refresh во время изменения/восстановления отключён. Серверные операции с неответившим устройством недоступны; просмотр истории не является разрешением восстановления. - Новый E2E проверяет начальный отказ, частичный отказ двух устройств, общий отказ и восстановление с нулём write-запросов; вместе с прежними операциями/таблицами прошло 15 E2E. TypeScript и lint чистые. Табличная IPsec-фикстура теперь содержит реальный inventory устройства вместо пустого списка. - Следующий блок: общий замок/контекст поздних операций, restore/export readiness и свежесть. Backend не менялся; последние build frontend — 9.47, backend — 9.44. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push. + +## Блок 9.50 — замок и контекст операций IPsec (2026-09-30) + +- Общий синхронный operationLock охватывает инициализацию, создание/редактирование клиентов/peer, удаление и восстановление до последующего чтения. Начало записи инвалидирует предыдущий опрос; внешний refresh при записи не отправляется. Восстановление и экспорт проверяют readiness устройства, ошибки остаются в своих панелях. +- Mounted-guard отсекает поздние успехи записи после смены источника: нет старого toast, повторного чтения или открытия сертификата. Ошибка удаления после размонтирования также игнорируется. Уже отправленная операция RouterOS не отменяется и может завершиться на прежнем backend — отмена UI не является отменой сетевой операции. +- Успешное удаление закрывает подтверждение даже при отказе последующего опроса; таблица сохраняет последние строки с явной ошибкой чтения и отключёнными действиями до повтора. Проверки не выдают это за неуспешное удаление. +- 30 E2E прошли: 6 отдельных тестов backend/mode контекста, 23 регрессии IPsec/истории (включая GRE smoke) и новый сценарий замка/ошибки чтения. TypeScript и lint чистые. Отдельная конфигурация ipsec-context исключена из обычной и использует порт 3101 последовательно. +- Далее — клиентская свежесть IPsec и профильные ПК/телефон экраны; затем оставшиеся VXLAN/GRE. Последние build frontend — 9.47, backend — 9.44. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push. diff --git a/playwright.config.ts b/playwright.config.ts index 932d3860..57864be9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/firewall-context.spec.ts", "**/filters-context.spec.ts"], + testIgnore: ["**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts"], fullyParallel: false, workers: 1, retries: 0, diff --git a/playwright.ipsec-context.config.ts b/playwright.ipsec-context.config.ts new file mode 100644 index 00000000..22453e20 --- /dev/null +++ b/playwright.ipsec-context.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@playwright/test" +import base from "./playwright.config" + +const server = base.webServer +if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер") +export default defineConfig({ + ...base, + use: { ...base.use, baseURL: "http://127.0.0.1:3101" }, + testIgnore: [], + testMatch: "ipsec-context.spec.ts", + webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local" } }, +}) diff --git a/tests/e2e/ipsec-context.spec.ts b/tests/e2e/ipsec-context.spec.ts new file mode 100644 index 00000000..b1c8fed1 --- /dev/null +++ b/tests/e2e/ipsec-context.spec.ts @@ -0,0 +1,79 @@ +import { test, expect, type Page } from "@playwright/test" +const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" } +const payload = (port: string) => ({ servers: [], clients: [{ id: "1-*1", rosId: "*1", serverId: "1", serverName: "Роутер", name: port === "8101" ? "Новый источник" : "Старый источник", authMethod: "certificate", kind: "cert", online: false, disabled: false, managed: true }] }) +async function setup(page: Page) { + let oldReads = 0 + await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } })) + await page.route("**/api/**", r => { + const url = new URL(r.request().url()), path = url.pathname + if (r.request().method() === "OPTIONS") return r.fulfill({ status: 204, headers }) + if (path === "/api/ipsec" && url.port === "8000") oldReads++ + return r.fulfill({ headers, json: path === "/api/ipsec" ? payload(url.port) : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, country: "RU" }] : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/settings") + await page.getByRole("link", { name: /^IPsec/ }).click() + await expect(page.locator("tr[data-row-id]")).toContainText("Старый источник") + return () => oldReads +} +async function change(page: Page, kind: string) { + await page.goBack() + await expect(page).toHaveURL(/settings/) + if (kind === "backend") { + const input = page.getByPlaceholder("http://localhost:8000") + await input.fill("http://localhost:8101") + await input.press("Enter") + } else await page.getByRole("button", { name: "Моковые", exact: true }).click() + await page.getByRole("link", { name: /^IPsec/ }).click() + if (kind === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новый источник") + else await expect(page.locator('tr[data-row-id="1-*1"]')).toHaveCount(0) +} +async function flush(page: Page) { + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) +} +for (const fails of [false, true]) { + test(`IPsec: позднее чтение старого backend ${fails}`, async ({ page }) => { + await setup(page) + let release: (() => void) | undefined, started = false + const gate = new Promise(resolve => { release = resolve }) + await page.route("**/api/ipsec", async r => { + if (new URL(r.request().url()).port !== "8000") return r.fallback() + started = true + await gate + return r.fulfill(fails ? { headers, status: 503, json: { error: "Старая ошибка чтения" } } : { headers, json: { servers: [], clients: [] } }) + }) + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect.poll(() => started).toBe(true) + await change(page, "backend") + const response = page.waitForResponse(r => r.url().includes(":8000/api/ipsec")) + release!() + await (await response).finished() + await flush(page) + await expect(page.locator("tr[data-row-id]")).toContainText("Новый источник") + await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить IPsec" })).toHaveCount(0) + }) +} +for (const kind of ["backend", "mode"]) for (const fails of [false, true]) { + test(`IPsec: позднее удаление после смены ${kind}/${fails}`, async ({ page }) => { + const reads = await setup(page) + let release: (() => void) | undefined, started = false + const gate = new Promise(resolve => { release = resolve }) + await page.route("**/api/ipsec/users/1/*", async r => { + started = true + await gate + return r.fulfill(fails ? { headers, status: 403, json: { error: "Старое удаление запрещено" } } : { headers, json: { ok: true } }) + }) + await page.getByRole("button", { name: "Удалить клиента", exact: true }).click() + await page.getByRole("alertdialog").getByRole("button", { name: "Удалить", exact: true }).click() + await expect.poll(() => started).toBe(true) + const before = reads() + await change(page, kind) + const response = page.waitForResponse(r => r.url().includes(":8000/api/ipsec/users/") && r.request().method() === "DELETE") + release!() + await (await response).finished() + await flush(page) + expect(reads()).toBe(before) + await expect(page.getByText("Удалено", { exact: true })).toHaveCount(0) + await expect(page.getByRole("alertdialog")).toHaveCount(0) + if (kind === "backend") await expect(page.getByRole("button", { name: "Новый клиент", exact: true })).toBeEnabled() + }) +} diff --git a/tests/e2e/ipsec-operation-lock.spec.ts b/tests/e2e/ipsec-operation-lock.spec.ts new file mode 100644 index 00000000..91b5ca74 --- /dev/null +++ b/tests/e2e/ipsec-operation-lock.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "@playwright/test" + +test("IPsec: удаление держит замок до обновления и отделяет ошибку чтения", async ({ page }) => { + const client = { id: "1-*1", rosId: "*1", serverId: "1", serverName: "Роутер", name: "Клиент тест", authMethod: "certificate", online: false, disabled: false, managed: true } + let reads = 0, writes = 0, release: (() => void) | undefined, phase = 0 + const gate = new Promise(resolve => { release = resolve }) + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", async r => { + const path = new URL(r.request().url()).pathname + if (r.request().method() === "DELETE") { writes++; await gate; phase = 1; return r.fulfill({ json: { ok: true } }) } + if (path === "/api/ipsec") { + reads++ + return phase === 1 ? r.fulfill({ status: 503, json: { error: "Опрос после удаления недоступен" } }) : r.fulfill({ json: { servers: [], clients: phase === 2 ? [] : [client] } }) + } + return r.fulfill({ json: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, country: "RU" }] : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/ipsec") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(1) + await page.getByRole("button", { name: "Удалить клиента", exact: true }).click() + const dialog = page.getByRole("alertdialog") + const submit = dialog.getByRole("button", { name: "Удалить", exact: true }) + await submit.evaluate(el => { (el as HTMLButtonElement).click(); (el as HTMLButtonElement).click() }) + await expect.poll(() => writes).toBe(1) + const before = reads + const refresh = page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true }) + await expect(refresh).toBeDisabled() + await refresh.evaluate(el => (el as HTMLButtonElement).click()) + expect(reads).toBe(before) + await expect(submit).toBeDisabled() + release!() + await expect(dialog).toHaveCount(0) + await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить IPsec" })).toContainText("Опрос после удаления недоступен") + await expect(rows).toHaveCount(1) + await expect(rows.getByRole("button", { name: "Удалить клиента" })).toBeDisabled() + phase = 2 + await page.getByRole("button", { name: "Повторить загрузку IPsec" }).click() + await expect(rows).toHaveCount(0) + await expect(page.getByText("Нет клиентов IKEv2", { exact: true })).toBeVisible() + expect(writes).toBe(1) +})