fix(ui): подтверждать удаление WireGuard и блокировать повторы
This commit is contained in:
1 parent
165f40acd7
commit
f980619046
5 files changed
+131
-13
No files matched your search
@@ -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
@@ -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
@@ -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 и общие компоненты; зависимости и сетевые операции не менялись.
|
||||
|
||||
@@ -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 не завершён.
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user