diff --git a/app/(main)/wireguard/page.tsx b/app/(main)/wireguard/page.tsx index e6a4c2f9..c8c1b28a 100644 --- a/app/(main)/wireguard/page.tsx +++ b/app/(main)/wireguard/page.tsx @@ -186,6 +186,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(false) const [busy, setBusy] = useState(false) + const toggleLock = useRef(false) + const [togglePending, setTogglePending] = useState(null) + const [toggleError, setToggleError] = useState(null) const [workspaceTab, setWorkspaceTab] = useState("interfaces") const [statusFilter, setStatusFilter] = useState("all") @@ -492,19 +495,28 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf } async function handleToggle(iface: WgIfaceWithServer) { + if (toggleLock.current) return if (sourceUnavailable(iface.serverId)) return if (!isLive || !iface.rosId) { toast.info("Доступно только в live-режиме") return } + toggleLock.current = true + const context = `${iface.name} · ${iface.serverName}` + setTogglePending(context) + setToggleError(null) try { - await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, { + const result = await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, { disabled: iface.enabled, }) + if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение интерфейса") toast.success(iface.enabled ? "Отключено" : "Включено") await loadLive() } catch (e) { - toast.error(e instanceof Error ? e.message : "Ошибка") + setToggleError(`${context}: ${e instanceof Error ? e.message : "Ошибка переключения"}`) + } finally { + toggleLock.current = false + setTogglePending(null) } } @@ -690,6 +702,8 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf )} + {togglePending &&

Изменение интерфейса {togglePending}…

} + {toggleError &&

{toggleError}

} openExport(iface, "rsc")} onAddPeer={selectedSourceError ? undefined : iface => { setPeerError(null); setPeerIface(iface) }} onToggle={selectedSourceError ? undefined : handleToggle} + toggleBusy={togglePending !== null} onDelete={selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })} onDeletePeer={selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })} onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)} diff --git a/components/data-grids/wireguard-data-grid.tsx b/components/data-grids/wireguard-data-grid.tsx index b8745f3f..631f7a97 100644 --- a/components/data-grids/wireguard-data-grid.tsx +++ b/components/data-grids/wireguard-data-grid.tsx @@ -51,6 +51,7 @@ interface WireguardDataGridProps { emptyAction?: ReactNode onExport: (iface: WgIfaceWithServer) => void onAddPeer?: (iface: WgIfaceWithServer) => void + toggleBusy?: boolean onToggle?: (iface: WgIfaceWithServer) => void onDelete?: (iface: WgIfaceWithServer) => void onDeletePeer?: (iface: WgIfaceWithServer, peerId: string) => void @@ -64,6 +65,7 @@ function WireguardDataGrid({ onExport, onAddPeer, onToggle, + toggleBusy = false, onDelete, onDeletePeer, onExportPeer, @@ -237,7 +239,7 @@ function WireguardDataGrid({ {onToggle && ( <> - onToggle(iface)}> + onToggle(iface)}> {iface.enabled ? "Отключить" : "Включить"} @@ -265,7 +267,7 @@ function WireguardDataGrid({ }, }, ], - [compactServer, onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer], + [compactServer, onExport, onAddPeer, onToggle, toggleBusy, onDelete, onDeletePeer, onExportPeer], ) const table = useTable({ diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index dbcd4269..0aa25663 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.4 — удаление, импорт и создание WireGuard); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. +По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.5 — операции 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.4. Далее — переключение интерфейсов и экспорт: повтор, подтверждение результата и контекст позднего ответа; затем IPsec/GRE/VXLAN/Firewall/Filters. +1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание интерфейса/пира и переключение WireGuard исправлены и проверены в 9.1–9.5. Далее — экспорт: ошибки, валидация ответа и контекст позднего ответа; затем 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 ecc295ee..3bc4e226 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.4 — удаление, импорт и создание WireGuard | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.5 — операции WireGuard; далее экспорт | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.5 — подтверждение переключения WireGuard (2026-09-29) + +- Переключение интерфейса защищено синхронным замком до ответа и обновления списка. Пункт меню недоступен во время операции; повторный вызов не отправляет второй PATCH. Никакого оптимистического изменения статуса нет. +- Успех требует ok=true. Ошибки API и неподтверждённый ответ остаются на странице через role=alert с именами интерфейса и сервера; ожидание объявляется через role=status. При следующей попытке прежняя ошибка очищается. +- Все 19 WireGuard E2E прошли. Новый сценарий проверяет точный сервер/RouterOS ID и тело PATCH, задержку, блокировку меню, 403, ok=false и успешный повтор. Итоговые lint, TypeScript и diff-check прошли. Production build не повторялся; последний успешный — блок 9.4. +- Получен ReUI checklist; существующие DataGrid/DropdownMenu и тема сохранены. Реальные RouterOS/JWT и физическое состояние туннеля локальными HTTP-моками не подтверждаются. Остальные типы операций не объединены в общий транзакционный замок. +- Далее — экспорт WireGuard: контекст выбранного интерфейса/пира, поздние ответы после закрытия/смены объекта, ошибки и валидация ответа. Затем остальные сетевые разделы по карте этапа 9. Локальный коммит без push. + ## Блок 9.4 — создание интерфейса и пира WireGuard (2026-09-29) - Создание интерфейса/пира и импорт используют общий синхронный замок формы. Повторный вызов не отправляет второй запрос; поля, переключатели, отмена и закрытие заблокированы до ответа. Ошибка остаётся внутри формы через role=alert, ожидание — role=status; значения сохраняются для повтора. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index f972d66e..6791b476 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.4. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. +Статус: начат 2026-09-28, выполнены блоки 9.1–9.5. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. ## Схема и референсы принятого плана @@ -57,3 +57,7 @@ ## Блок 9.4 — создание интерфейса и пира (2026-09-29) Формы блокируют повтор/закрытие/изменение до ответа и сохраняют ввод при ошибке. Проверяются оба backend-ответа создания интерфейса (DTO либо ok/name) и ok=true создания пира. Поля и переключатели получили доступные имена; повторное открытие пира сбрасывает ввод. Все 18 E2E прошли, три новых повторены после уточнения переключателей; lint и TypeScript прошли. Итог сборки — в журнале. Следующие проверки: переключение интерфейса и экспорт с защитой контекста. Реальный RouterOS/JWT и транзакционность составной операции не подтверждены локальными моками. + +## Блок 9.5 — переключение интерфейса (2026-09-29) + +Повторы блокируются до PATCH и обновления списка; успех требует ok=true. Ожидание и ошибка с именами интерфейса/сервера объявляются через status/alert. Все 19 E2E, lint, TypeScript и diff-check прошли. Следующий блок — экспорт, включая поздние ответы и смену контекста. Реальное изменение RouterOS не проверено. diff --git a/tests/e2e/wireguard-links.spec.ts b/tests/e2e/wireguard-links.spec.ts index 1955ffad..7ceca444 100644 --- a/tests/e2e/wireguard-links.spec.ts +++ b/tests/e2e/wireguard-links.spec.ts @@ -314,3 +314,40 @@ for (const kind of ["interface", "interface-dto", "peer"] as const) { await expect(dialog).toHaveCount(0) }) } + +test("WireGuard: переключение ждёт подтверждения и защищено от повтора", async ({ page }) => { + await setup(page, "/wireguard?server=1&interface=*%2F2") + let attempts = 0 + let release: (() => void) | undefined + const gate = new Promise(resolve => { release = resolve }) + await page.route("**/api/wireguard/interfaces/**", async route => { + expect(route.request().method()).toBe("PATCH") + expect(decodeURIComponent(new URL(route.request().url()).pathname)).toBe("/api/wireguard/interfaces/1/*/2") + expect(route.request().postDataJSON()).toEqual({ disabled: true }) + attempts++ + if (attempts === 1) { + await gate + return route.fulfill({ status: 403, json: { error: "Нет доступа к переключению" } }) + } + return route.fulfill({ json: { ok: attempts > 2 } }) + }) + const menu = page.getByRole("button", { name: "Действия: wg-same" }) + await menu.click() + await page.getByRole("menuitem", { name: "Отключить", exact: true }).click() + await expect.poll(() => attempts).toBe(1) + await expect(page.getByRole("status").filter({ hasText: "Изменение интерфейса wg-same · Источник" })).toBeVisible() + await menu.click() + await expect(page.getByRole("menuitem", { name: "Отключить", exact: true })).toBeDisabled() + await page.keyboard.press("Escape") + expect(attempts).toBe(1) + release!() + await expect(page.getByRole("alert").filter({ hasText: "wg-same · Источник: Нет доступа к переключению" })).toBeVisible() + await menu.click() + await page.getByRole("menuitem", { name: "Отключить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил изменение интерфейса" })).toBeVisible() + await menu.click() + await page.getByRole("menuitem", { name: "Отключить", exact: true }).click() + await expect(page.getByText("Отключено", { exact: true })).toBeVisible() + await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил" })).toHaveCount(0) + expect(attempts).toBe(3) +})