diff --git a/app/(main)/wireguard/page.tsx b/app/(main)/wireguard/page.tsx index 3242f1cc..c128205d 100644 --- a/app/(main)/wireguard/page.tsx +++ b/app/(main)/wireguard/page.tsx @@ -1,6 +1,6 @@ "use client" -import { Suspense, useCallback, useEffect, useMemo, useState } from "react" +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" import { PageHeader } from "@/components/page-header" @@ -197,6 +197,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf const [exportPeerId, setExportPeerId] = useState(null) const [peerIface, setPeerIface] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) + const deleteLock = useRef(false) + const [deleteBusy, setDeleteBusy] = useState(false) + const [deleteError, setDeleteError] = useState(null) const [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) const [historyLoading, setHistoryLoading] = useState(false) @@ -490,29 +493,38 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf } async function confirmDelete() { - if (!pendingDelete) return + if (!pendingDelete || deleteLock.current) return if (sourceUnavailable(pendingDelete.iface.serverId)) return if (!isLive) { toast.info("Доступно только в live-режиме") setPendingDelete(null) return } + deleteLock.current = true + setDeleteBusy(true) + setDeleteError(null) try { if (pendingDelete.kind === "iface") { if (!pendingDelete.iface.rosId) { toast.info("Доступно только в live-режиме") return } - await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId) + const result = await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId) + if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление") toast.success("Удалено") } else { - await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, pendingDelete.peerId) + const peer = pendingDelete.iface.peers.find(p => p.id === pendingDelete.peerId || p.rosId === pendingDelete.peerId) + const result = await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, peer?.rosId ?? pendingDelete.peerId) + if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление") toast.success("Пир удалён") } setPendingDelete(null) await loadLive() } catch (e) { - toast.error(e instanceof Error ? e.message : "Ошибка удаления") + setDeleteError(e instanceof Error ? e.message : "Ошибка удаления") + } finally { + deleteLock.current = false + setDeleteBusy(false) } } @@ -887,7 +899,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf onRequestLiveExport={isLive ? handleLiveExport : undefined} /> - { if (!v) setPendingDelete(null) }}> + { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) } }}> @@ -904,10 +916,12 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf : null} + {deleteBusy &&

Ожидание подтверждения удаления…

} + {deleteError &&

{deleteError}

} - setPendingDelete(null)}>Отмена - void confirmDelete()}> - Удалить + Отмена + void confirmDelete()}> + {deleteBusy ? "Удаление…" : "Удалить"}
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index ac1336cf..a2cc9ff6 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -80,15 +80,15 @@ flowchart LR ## Текущая точка продолжения -По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этапы 9–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. +По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блок 9.1 — удаление WireGuard); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. -Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md). +Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.md). Этап 8 завершён блоком 8.13: периоды и URL статистики, достоверность REST/SSE, защита от гонок, свежесть и разрывы графиков, месячный отчёт с каталогом неактивных серверов, адаптивность и профильная клавиатурная приёмка. Повреждённые ответы статистики показывают ошибку с повтором. Точный состав проверок и ограничений сохранён в журнале. Ближайший оставшийся объём: -1. Этап 9: инвентаризация WireGuard, IPsec, GRE, VXLAN, Firewall и Filters; схема «устройство → объект → форма → подтверждённый результат», карта API/прав и первый законченный блок. +1. Этап 9: карта разделов и схема сохранены; удаление WireGuard исправлено и проверено в 9.1. Далее — создание/импорт/добавление пира: контекст формы, ошибка и повтор, различие dry-run и применения; затем 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 9cda0622..09ea44cf 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -25,12 +25,21 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | Не начат | +| 9 | VPN, Firewall и Filters | В работе: блок 9.1 — подтверждение удаления WireGuard | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.1 — подтверждение удаления WireGuard (2026-09-28) + +- Сохранена [карта этапа 9 со схемой и референсами](ux-ui-stage-9.md). Первичная инвентаризация API и контекста выполнена; полный аудит остальных операций впереди. +- Удаление интерфейса/пира WireGuard защищено синхронной блокировкой повторного запроса. До ответа недоступны кнопки и закрытие диалога. Ожидание и ошибка объявляются через status/alert. При ошибке объект сохраняется для повтора; успех требует ok=true. +- E2E выявил передачу ID строки пира вместо отличающегося RouterOS ID. Исправлено: при наличии используется rosId. Backend-контракт удаления и сетевые правила не менялись. +- Итоговые 13 WireGuard E2E прошли: ссылки/контекст, таблицы, оба удаления с задержкой, 403, ok=false и успешным повтором. Проверены точные сервер/ID DELETE, блокировка кнопок и Escape. Lint, TypeScript и diff-check прошли. Полная сборка не повторялась для локального изменения обработчика; последняя успешная сборка — 8.13. +- Получен ReUI checklist; используются установленные компоненты без изменения темы. Реальные RouterOS/JWT не проверены. HTTP-моки подтверждают клиентское поведение, а не сетевой результат. Этап 9 открыт. +- Следующий шаг: создание/импорт/добавление пира WireGuard — сохранение формы и контекста, защита повторов, различие dry-run и применения. На контрольной точке использовано 75% пятичасового и 43% недельного лимита; новые блоки в этом окне не начинать. Локальный коммит без push. + ## Блок 8.13 — локальная приёмка трафика и статистики (2026-09-28) - Добавлены доступные имена полей панели подключения jump-host и измерений статистики, aria-pressed общего SegmentedControl и role=alert ошибки flow. Сохранены установленные ReUI/base-nova, Frame и общие компоненты; зависимости и сетевые операции не менялись. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md new file mode 100644 index 00000000..5ffbac33 --- /dev/null +++ b/docs/ux-ui-stage-9.md @@ -0,0 +1,47 @@ +# Этап 9: VPN и сетевые операции + +Статус: начат 2026-09-28, выполнен блок 9.1. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. + +## Схема и референсы принятого плана + +| Область | Референс | Схема доработки | +| --- | --- | --- | +| Устройство и объект | [List 9](https://reui.io/preview/base/list-9?ref=mcp) | Устройство → список → точный объект | +| Формы | [Sheet 8](https://reui.io/preview/base/sheet-8?ref=mcp) | Контекст → ввод → ожидание → результат / ошибка с сохранением ввода | +| Таблицы | [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | Фильтры → таблица устройства → действие | + +Референсы сохранены из общего плана; новые registry-компоненты не устанавливались. + +```text +[Устройство / URL] → [Объект и состояние данных] + ↓ + [Форма / подтверждение] + ↓ + [Один запрос · ожидание ответа] + ↙ ↘ + [Ошибка + повтор] [Подтверждение API] + контекст сохранён ↓ обновить список +``` + +## Карта работ + +| Раздел | Точки интеграции | Следующая проверка | +| --- | --- | --- | +| WireGuard | app/(main)/wireguard/page.tsx, components/wireguard, shared/api/wireguard.ts; URL server/interface | Создание, импорт, пиры, переключение и экспорт: ввод, повтор, ответ и права | +| IPsec | app/(main)/ipsec/page.tsx, components/ipsec, shared/api/ipsec.ts | Выбор устройства, инициализация, сертификаты/пользователи и ошибки | +| GRE | app/(main)/gre/page.tsx; /api/gre/tunnels, revisions; URL-контекст | Запись/удаление и восстановление, повторные нажатия | +| VXLAN | app/(main)/vxlan/page.tsx; /api/vxlan; URL server/tunnel | Загрузка/сбой/повтор, контекст и существующие действия | +| Firewall | app/(main)/firewall/page.tsx; правила, адреса, перемещение и история | Достоверность записи, права и контекст устройства | +| Filters | app/(main)/filters/page.tsx; /api/filters/rules, revisions, gre-tunnels, recursive-routes | Сохранение/применение, ошибки зависимостей и устройство | + +Инвентаризация определяет точки следующего аудита; перечисление не означает проверки всех обработчиков. + +## Блок 9.1 — удаление WireGuard + +- Диалог удаления интерфейса/пира блокирует повтор и закрытие до ответа. Ожидание — role=status, ошибка — role=alert; при ошибке сохраняются объект и диалог для повтора. +- Успех требует ok=true по контракту backend. HTTP 200 с ok=false не закрывает диалог и не показывает успех. +- При различии ID строки и RouterOS ID для пира используется rosId. Прежний ID остаётся запасным вариантом при отсутствии rosId. +- Прошли 13 E2E WireGuard: таблицы, URL/устройство, ошибки загрузки и оба удаления с задержкой, 403, неподтверждённым ответом и успешным повтором. Проверяются сервер и ID в DELETE. Lint, TypeScript и diff-check прошли. +- Сохранён установленный AlertDialog/Base UI, без изменения темы/registry. Получен ReUI audit checklist; изменены поведение и доступные состояния. + +Далее: создание/импорт/добавление пира WireGuard — сохранение формы, повторные операции, различие dry-run и применения. Затем остальные разделы по карте. Реальные RouterOS/JWT и сетевые последствия требуют отдельного окружения. Этап 9 не завершён. diff --git a/tests/e2e/wireguard-links.spec.ts b/tests/e2e/wireguard-links.spec.ts index fdab790a..e394de70 100644 --- a/tests/e2e/wireguard-links.spec.ts +++ b/tests/e2e/wireguard-links.spec.ts @@ -127,3 +127,51 @@ for (const status of [403, 500]) { expect(control.posts).toEqual([]) }) } + +for (const kind of ["iface", "peer"] as const) { + test("WireGuard: подтверждение удаления и повтор после ошибки " + kind, 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/" + (kind === "iface" ? "interfaces" : "peers") + "/**", async route => { + expect(route.request().method()).toBe("DELETE") + expect(decodeURIComponent(new URL(route.request().url()).pathname)).toBe( + kind === "iface" ? "/api/wireguard/interfaces/1/*/2" : "/api/wireguard/peers/1/*P", + ) + attempts++ + if (attempts === 1) { + await gate + await route.fulfill({ status: 403, json: { error: "Нет доступа к удалению" } }) + } else if (attempts === 2) { + await route.fulfill({ json: { ok: false } }) + } else { + await route.fulfill({ json: { ok: true } }) + } + }) + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + if (kind === "iface") { + await page.getByRole("button", { name: "Действия: wg-same" }).click() + await page.getByRole("menuitem", { name: /Удалить/ }).click() + } else { + await page.getByRole("tab", { name: /Пиры/ }).click() + await page.getByRole("button", { name: "Удалить пира", exact: true }).click() + } + const dialog = page.getByRole("alertdialog") + await expect(dialog).toContainText("Источник") + await dialog.getByRole("button", { name: "Удалить", exact: true }).click() + await expect(dialog.getByRole("button", { name: "Удаление…" })).toBeDisabled() + await expect(dialog.getByRole("button", { name: "Отмена" })).toBeDisabled() + await page.keyboard.press("Escape") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("status")).toContainText("Ожидание подтверждения") + expect(attempts).toBe(1) + release!() + await expect(dialog.getByRole("alert")).toContainText("Нет доступа") + await dialog.getByRole("button", { name: "Удалить", exact: true }).click() + await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил удаление") + await dialog.getByRole("button", { name: "Удалить", exact: true }).click() + await expect(dialog).toHaveCount(0) + expect(attempts).toBe(3) + }) +}