diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 19b18975..51acca05 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 [readReceipt, setReadReceipt] = useState>({}) + const [responseReceipt, setResponseReceipt] = useState(null) + const [writeReceipt, setWriteReceipt] = useState<{ serverId: string; at: string } | null>(null) const mounted = useRef(true) const operationLock = useRef(false) useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, []) @@ -239,6 +242,10 @@ function IpsecPage() { setSummaries(previous => [...ipsec.servers, ...previous.filter(s => failedIds.has(s.serverId))]) setClients(previous => [...ipsec.clients, ...previous.filter(c => failedIds.has(c.serverId))]) setFailures(ipsec.failures ?? []) + const at = new Date().toISOString() + setResponseReceipt(at) + const successfulIds = new Set([...ipsec.servers.map(s => s.serverId), ...ipsec.clients.map(c => c.serverId)]) + setReadReceipt(previous => ({ ...previous, ...Object.fromEntries([...successfulIds].map(id => [id, at])) })) setHasLoaded(true) setDataError(null) } else { @@ -292,6 +299,7 @@ function IpsecPage() { const res = await restoreIpsecRevision(backendUrl, id, historyServerId) if (!mounted.current) return if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление") + confirmOperation(historyServerId) toast.success("Версия применена на роутер") await loadLive(true) await loadRevisions() @@ -311,6 +319,10 @@ function IpsecPage() { const assertDeviceReady = (serverId: string) => { if (operationLock.current || !canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec") } + const confirmOperation = (serverId: string) => setWriteReceipt({ serverId, at: new Date().toISOString() }) + const formatReceipt = (at: string) => new Date(at).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) + const scopeReceipt = effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId] + const scopeWriteReceipt = writeReceipt && (effectiveServerId === ALL_SERVERS_ID || writeReceipt.serverId === effectiveServerId) ? writeReceipt : null const beginOperation = (serverId: string) => { assertDeviceReady(serverId) operationLock.current = true @@ -407,6 +419,7 @@ function IpsecPage() { createNatRule: form.createNatRule, }) if (!mounted.current) return + confirmOperation(form.serverId) toast.success("IKEv2-сервер инициализирован") setInitOpen(false) await loadLive(true) @@ -429,6 +442,7 @@ function IpsecPage() { passphrase: form.passphrase.trim() || undefined, }) if (!mounted.current) return + confirmOperation(form.serverId) toast.success(`Клиент «${form.name.trim()}» создан`) setUserOpen(false) setEditing(null) @@ -459,6 +473,7 @@ function IpsecPage() { psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined, }) if (!mounted.current) return + confirmOperation(editing.serverId) toast.success("Клиент обновлён") setUserOpen(false) setEditing(null) @@ -535,6 +550,7 @@ function IpsecPage() { await deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force }) } if (!mounted.current) return + confirmOperation(serverId) toast.success("Удалено") setPendingDelete(null) setForceAllowed(false) @@ -568,6 +584,7 @@ function IpsecPage() { disabled: form.disabled, }) if (!mounted.current) return + confirmOperation(editingPeer.serverId) toast.success("Peer обновлён") setPeerOpen(false) setEditingPeer(null) @@ -688,6 +705,13 @@ function IpsecPage() { ]} /> + {isLive &&
+ {effectiveServerId === ALL_SERVERS_ID ? "Последний ответ IPsec получен в приложении: " : "Устройство опрошено, ответ получен в приложении: "} + {scopeReceipt ? : "неизвестно"} + + {scopeWriteReceipt && Изменение подтверждено API: } +
} + {isLive && (dataError || inventoryError) ? ( diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 66f6b7e2..b5996753 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.50 — IPsec/GRE/VXLAN, Firewall и операции Filters | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.51 — IPsec/GRE/VXLAN, Firewall и операции Filters | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.51 — свежесть IPsec (2026-09-30) + +- По выбранному устройству показано клиентское время получения успешного ответа; по всем устройствам — время последнего валидного ответа IPsec, которое не означает успешный опрос каждого роутера. Частичный отказ сохраняет прошлое время неответившего устройства, полный отказ не меняет ни одну дату. До первого успешного результата время неизвестно; время отмечается только для устройств, присутствующих в успешных сводках/клиентах. +- Подтверждённые изменения/восстановление показывают отдельный timestamp API с serverId. Он не заменяет последующий опрос. ISO доступен через time.dateTime, title поясняет клиентские часы; даты не сохраняются между перезагрузками, возраст не обновляется таймером. +- 8 E2E прошли: данные/схемы, новый тест управляемых часов с выбором устройства и расширенный замок удаления/ошибка опроса с подтверждением записи. TypeScript и lint чистые. Backend не менялся, build пока не повторялся. +- Далее — профильная клавиатурная и адаптивная приёмка IPsec, проверенные экраны в плане; затем оставшиеся VXLAN/GRE. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push. + ## Блок 9.50 — замок и контекст операций IPsec (2026-09-30) - Общий синхронный operationLock охватывает инициализацию, создание/редактирование клиентов/peer, удаление и восстановление до последующего чтения. Начало записи инвалидирует предыдущий опрос; внешний refresh при записи не отправляется. Восстановление и экспорт проверяют readiness устройства, ошибки остаются в своих панелях. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 3fa7080e..969cb5eb 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -264,3 +264,10 @@ Live-копирование читает актуальный целевой rul - Успешное удаление закрывает подтверждение даже при отказе последующего опроса; таблица сохраняет последние строки с явной ошибкой чтения и отключёнными действиями до повтора. Проверки не выдают это за неуспешное удаление. - 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.51 — свежесть IPsec (2026-09-30) + +- По выбранному устройству показано клиентское время получения успешного ответа; по всем устройствам — время последнего валидного ответа IPsec, которое не означает успешный опрос каждого роутера. Частичный отказ сохраняет прошлое время неответившего устройства, полный отказ не меняет ни одну дату. До первого успешного результата время неизвестно; время отмечается только для устройств, присутствующих в успешных сводках/клиентах. +- Подтверждённые изменения/восстановление показывают отдельный timestamp API с serverId. Он не заменяет последующий опрос. ISO доступен через time.dateTime, title поясняет клиентские часы; даты не сохраняются между перезагрузками, возраст не обновляется таймером. +- 8 E2E прошли: данные/схемы, новый тест управляемых часов с выбором устройства и расширенный замок удаления/ошибка опроса с подтверждением записи. TypeScript и lint чистые. Backend не менялся, build пока не повторялся. +- Далее — профильная клавиатурная и адаптивная приёмка IPsec, проверенные экраны в плане; затем оставшиеся VXLAN/GRE. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push. diff --git a/tests/e2e/ipsec-freshness.spec.ts b/tests/e2e/ipsec-freshness.spec.ts new file mode 100644 index 00000000..ba8dbfe8 --- /dev/null +++ b/tests/e2e/ipsec-freshness.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test" + +test("IPsec: время успешного опроса сохраняется при частичном/полном отказе", async ({ page }) => { + await page.clock.install({ time: new Date("2026-09-30T05:00:00Z") }) + const summary = (id: string) => ({ serverId: id, serverName: "Роутер " + id, initialized: false, ike2Ready: false, peers: [], natRuleManaged: false, clientsTotal: 0, clientsOnline: 0 }) + let phase = 0 + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (path === "/api/ipsec" && phase === 2) return r.fulfill({ status: 503, json: { error: "Опрос недоступен тест" } }) + return r.fulfill({ json: path === "/api/ipsec" ? { servers: phase === 1 ? [summary("1")] : [summary("1"), summary("2")], clients: [], failures: phase === 1 ? [{ serverId: "2", error: "Таймаут тест" }] : [] } + : path === "/api/servers" ? [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU" })) + : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/ipsec") + const time = page.getByRole("group", { name: "Свежесть IPsec" }).locator('[data-source-time="read"] time') + await expect(time).toHaveCount(1) + await page.getByRole("option").filter({ hasText: "Роутер 2" }).click() + const first = await time.getAttribute("datetime") + phase = 1 + await page.clock.fastForward(60_000) + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Не удалось опросить часть роутеров" })).toBeVisible() + await expect(time).toHaveAttribute("datetime", first!) + phase = 2 + await page.clock.fastForward(60_000) + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить IPsec" })).toBeVisible() + await expect(time).toHaveAttribute("datetime", first!) + phase = 3 + await page.clock.fastForward(60_000) + await page.getByRole("button", { name: "Повторить загрузку IPsec" }).click() + await expect(time).not.toHaveAttribute("datetime", first!) + 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 index 91b5ca74..345d2a82 100644 --- a/tests/e2e/ipsec-operation-lock.spec.ts +++ b/tests/e2e/ipsec-operation-lock.spec.ts @@ -30,6 +30,7 @@ test("IPsec: удаление держит замок до обновления await expect(submit).toBeDisabled() release!() await expect(dialog).toHaveCount(0) + await expect(page.getByRole("group", { name: "Свежесть IPsec" }).locator('[data-source-time="write"] time')).toHaveCount(1) await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить IPsec" })).toContainText("Опрос после удаления недоступен") await expect(rows).toHaveCount(1) await expect(rows.getByRole("button", { name: "Удалить клиента" })).toBeDisabled()