diff --git a/app/(main)/wireguard/page.tsx b/app/(main)/wireguard/page.tsx index c8c1b28a..73b7bf89 100644 --- a/app/(main)/wireguard/page.tsx +++ b/app/(main)/wireguard/page.tsx @@ -217,6 +217,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf peerConf?: string } | null>(null) const [exportBusy, setExportBusy] = useState(false) + const exportEpoch = useRef(0) + const exportPending = useRef(null) + useEffect(() => () => { exportEpoch.current += 1 }, []) const loadLive = useCallback(async () => { if (!isLive) return @@ -593,7 +596,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf } async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") { - if (!exportIface || !isLive) return + if (!exportIface || !isLive || exportPending.current !== null) return + const epoch = exportEpoch.current + exportPending.current = epoch setExportBusy(true) try { const res = await exportWireGuard(backendUrl, { @@ -603,6 +608,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf includePrivateKey: format !== "peer-conf", peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined, }) + if (epoch !== exportEpoch.current) return setLiveExport((prev) => ({ ...prev, ...(format === "rsc" @@ -613,13 +619,20 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf })) toast.success("Конфиг загружен с роутера") } catch (e) { + if (epoch !== exportEpoch.current) return toast.error(e instanceof Error ? e.message : "Ошибка экспорта") } finally { - setExportBusy(false) + if (epoch === exportEpoch.current) { + exportPending.current = null + setExportBusy(false) + } } } function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) { + exportEpoch.current += 1 + exportPending.current = null + setExportBusy(false) setLiveExport(null) setExportInitialTab(tab) setExportPeerId(peerId ?? null) @@ -927,6 +940,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf initialTab={exportInitialTab} peerId={exportPeerId} onClose={() => { + exportEpoch.current += 1 + exportPending.current = null + setExportBusy(false) setExportIface(null) setExportPeerId(null) setExportInitialTab("rsc") diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 0aa25663..1eec11bd 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.5 — операции WireGuard); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. +По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.6 — операции и защита экспорта WireGuard); этапы 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.5. Далее — экспорт: ошибки, валидация ответа и контекст позднего ответа; затем IPsec/GRE/VXLAN/Firewall/Filters. +1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение и контекст экспорта WireGuard исправлены в 9.1–9.6. Далее — точный выбор пира в экспорте и история/восстановление; затем 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 3bc4e226..8437abfd 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.5 — операции WireGuard; далее экспорт | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.6 — операции и защита экспорта WireGuard | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.6 — контекст ответа экспорта WireGuard (2026-09-29) + +- Каждое открытие/закрытие панели меняет поколение запроса. Поздние результат, ошибка и finally старого экспорта не меняют новую панель и не снимают её busy. Размонтирование также инвалидирует ответ. Повторный запрос в текущей панели защищён синхронной блокировкой; закрытие доступно. +- Ответ проверяется схемой wgExportResponseSchema и совпадением формата с запросом. Повреждённый ответ/чужой формат не заменяет конфиг и не объявляется успешным экспортом. Локальное содержимое остаётся локальным; общая композиция CodeExportSheet не менялась. +- Три новых E2E прошли: поздний успех, поздняя ошибка после открытия другого сервера, повреждённый ответ/другой формат и успешный повтор. Проверяется двойное нажатие. Итоговый набор из 22 WireGuard E2E, lint, TypeScript и diff-check прошли. Первоначально исправлен селектор теста под существующий aria-label кнопки. +- Получен ReUI checklist. Production build не повторялся (последний — 9.4), реальные RouterOS/JWT/ключи не проверены. Запрос после закрытия не отменяется на сервере, только игнорируется в UI. Тесты используют фиктивное содержимое без секретов. +- Далее: точность выбора пира в экспорте (ID/rosId, отсутствие подстановки первого пира при неизвестном ID), затем история/восстановление WireGuard и остальные сетевые разделы. На контрольной точке использовано 69% пятичасового и 58% недельного лимита. Локальный коммит без push. + ## Блок 9.5 — подтверждение переключения WireGuard (2026-09-29) - Переключение интерфейса защищено синхронным замком до ответа и обновления списка. Пункт меню недоступен во время операции; повторный вызов не отправляет второй PATCH. Никакого оптимистического изменения статуса нет. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 6791b476..17eba720 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.5. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. +Статус: начат 2026-09-28, выполнены блоки 9.1–9.6. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. ## Схема и референсы принятого плана @@ -61,3 +61,7 @@ ## Блок 9.5 — переключение интерфейса (2026-09-29) Повторы блокируются до PATCH и обновления списка; успех требует ok=true. Ожидание и ошибка с именами интерфейса/сервера объявляются через status/alert. Все 19 E2E, lint, TypeScript и diff-check прошли. Следующий блок — экспорт, включая поздние ответы и смену контекста. Реальное изменение RouterOS не проверено. + +## Блок 9.6 — контекст экспорта (2026-09-29) + +Результат, ошибка и завершение запроса привязаны к открытию панели; закрытие/новое открытие/размонтирование инвалидируют прежний ответ. Повтор защищён замком. Схема ответа и запрошенный формат проверяются. Три новых E2E, lint и TypeScript прошли; итог общего набора — в журнале. Далее: точный выбор пира, затем история/восстановление. Реальный экспорт ключей не проверен; сетевой запрос после закрытия не отменяется. diff --git a/shared/api/wireguard.ts b/shared/api/wireguard.ts index e0aad089..72368d08 100644 --- a/shared/api/wireguard.ts +++ b/shared/api/wireguard.ts @@ -1,4 +1,4 @@ -import { wgIfaceDtoSchema, wgImportResponseSchema } from "@mmapp/contracts/wireguard" +import { wgIfaceDtoSchema, wgImportResponseSchema, wgExportResponseSchema } from "@mmapp/contracts/wireguard" import type { WgCreateInterface, WgCreatePeerRequest, @@ -116,8 +116,13 @@ export async function exportWireGuard( baseUrl: string, payload: WgExportRequest, ): Promise { - return requestJson(baseUrl, "/api/wireguard/export", { + const response = await requestJson(baseUrl, "/api/wireguard/export", { method: "POST", body: JSON.stringify(payload), }) + const parsed = wgExportResponseSchema.safeParse(response) + if (!parsed.success || parsed.data.format !== payload.format) { + throw new Error("Некорректный ответ экспорта WireGuard") + } + return parsed.data } diff --git a/tests/e2e/wireguard-links.spec.ts b/tests/e2e/wireguard-links.spec.ts index 7ceca444..607b1f47 100644 --- a/tests/e2e/wireguard-links.spec.ts +++ b/tests/e2e/wireguard-links.spec.ts @@ -351,3 +351,67 @@ test("WireGuard: переключение ждёт подтверждения и await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил" })).toHaveCount(0) expect(attempts).toBe(3) }) + +for (const oldFails of [false, true]) { + test("WireGuard: поздний экспорт не меняет новую панель " + oldFails, async ({ page }) => { + await setup(page, "/wireguard") + let release: (() => void) | undefined + const gate = new Promise(resolve => { release = resolve }) + let requests = 0 + await page.route("**/api/wireguard/export", async route => { + requests++ + const body = route.request().postDataJSON() + expect(body.format).toBe("rsc") + if (body.serverId === "1") { + await gate + return oldFails ? route.fulfill({ status: 500, json: { error: "Старая ошибка экспорта" } }) : route.fulfill({ json: { format: "rsc", filename: "old.rsc", content: "OLD_EXPORT" } }) + } + expect(body.serverId).toBe("2") + return route.fulfill({ json: { format: "rsc", filename: "new.rsc", content: "NEW_EXPORT" } }) + }) + const open = async (index: number) => { + await page.getByRole("button", { name: "Действия: wg-same" }).nth(index).click() + await page.getByRole("menuitem", { name: "Экспорт", exact: true }).click() + } + await expect(page.locator("tr[data-row-id]")).toHaveCount(4) + await open(0) + const dialog = page.getByRole("dialog", { name: "Экспорт WireGuard" }) + await dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true }).evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + await expect.poll(() => requests).toBe(1) + await expect(dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true })).toBeDisabled() + await page.keyboard.press("Escape") + await expect(dialog).toHaveCount(0) + await open(2) + await expect(dialog).toContainText("Второй сервер") + await dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true }).click() + await expect(dialog.locator("pre")).toContainText("NEW_EXPORT") + const oldResponse = page.waitForResponse(response => response.url().endsWith("/api/wireguard/export") && response.request().postDataJSON().serverId === "1") + release!() + await (await oldResponse).finished() + await page.waitForTimeout(150) + await expect(dialog.locator("pre")).toContainText("NEW_EXPORT") + await expect(page.getByText("Старая ошибка экспорта", { exact: true })).toHaveCount(0) + expect(requests).toBe(2) + }) +} + +test("WireGuard: экспорт отклоняет повреждённый ответ и другой формат", async ({ page }) => { + await setup(page, "/wireguard?server=1&interface=*%2F2") + let attempts = 0 + await page.route("**/api/wireguard/export", route => { + attempts++ + return route.fulfill({ json: attempts === 1 ? {} : attempts === 2 ? { format: "conf", filename: "wrong.conf", content: "WRONG_FORMAT" } : { format: "rsc", filename: "valid.rsc", content: "VALID_EXPORT" } }) + }) + await page.getByRole("button", { name: "Действия: wg-same" }).click() + await page.getByRole("menuitem", { name: "Экспорт", exact: true }).click() + const dialog = page.getByRole("dialog", { name: "Экспорт WireGuard" }) + for (let i = 0; i < 2; i++) { + await dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true }).click() + await expect.poll(() => attempts).toBe(i + 1) + await expect(page.getByText("Некорректный ответ экспорта WireGuard", { exact: true }).first()).toBeVisible() + await expect(dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true })).toBeEnabled() + await expect(dialog.locator("pre")).not.toContainText("WRONG_FORMAT") + } + await dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true }).click() + await expect(dialog.locator("pre")).toContainText("VALID_EXPORT") +})