fix(ui): подтверждать удаление WireGuard и блокировать повторы

This commit is contained in:
Denozordec committed 2026-09-28 23:52:43 +07:00
1 parent 165f40acd7
commit f980619046
5 files changed
+131 -13

No files matched your search

+23 -9
View File
@@ -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<string | null>(null)
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
const deleteLock = useRef(false)
const [deleteBusy, setDeleteBusy] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
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}
/>
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) } }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
@@ -904,10 +916,12 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
: null}
</AlertDialogDescription>
</AlertDialogHeader>
{deleteBusy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения удаления…</p>}
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => void confirmDelete()}>
Удалить
<AlertDialogCancel disabled={deleteBusy}>Отмена</AlertDialogCancel>
<AlertDialogAction type="button" variant="destructive" disabled={deleteBusy} onClick={() => void confirmDelete()}>
{deleteBusy ? "Удаление…" : "Удалить"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+3 -3
View File
@@ -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 — остаются явно зафиксированными, без заявления о реализации.
+10 -1
View File
@@ -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 и общие компоненты; зависимости и сетевые операции не менялись.
+47
View File
@@ -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 не завершён.
+48
View File
@@ -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<void>(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)
})
}