fix(ui): защитить форму импорта WireGuard во время запроса

This commit is contained in:
Denozordec committed 2026-09-29 08:22:58 +07:00
1 parent efc6da1249
commit ec6ec52965
6 files changed
+89 -16

No files matched your search

+10 -3
View File
@@ -192,6 +192,8 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const importLock = useRef(false)
const [importError, setImportError] = useState<string | null>(null)
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
@@ -443,10 +445,13 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
format: "auto" | "rsc" | "conf"
dryRun: boolean
}) {
if (importLock.current) return
if (!isLive) {
toast.info("Импорт на роутер доступен только в live-режиме")
return
}
importLock.current = true
setImportError(null)
setBusy(true)
try {
const res = await importWireGuard(backendUrl, {
@@ -467,8 +472,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
setImportOpen(false)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка импорта")
setImportError(e instanceof Error ? e.message : "Ошибка импорта")
} finally {
importLock.current = false
setBusy(false)
}
}
@@ -647,7 +653,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
</Button>
</>
)}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<Button size="sm" variant="outline" onClick={() => { setImportError(null); setImportOpen(true) }}>
<UploadIcon className="size-4" />
Импорт
</Button>
@@ -874,7 +880,8 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
/>
<WgImportSheet
open={importOpen}
onOpenChange={setImportOpen}
onOpenChange={v => { if (!importLock.current) setImportOpen(v) }}
error={importError}
servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy}
+19 -9
View File
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useMemo, useState } from "react"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import {
@@ -17,6 +17,7 @@ function WgImportSheet({
onOpenChange,
servers,
busy,
error,
defaultServerId,
onImport,
}: {
@@ -24,6 +25,7 @@ function WgImportSheet({
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
error?: string | null
defaultServerId?: string
onImport: (args: {
serverId: string
@@ -43,10 +45,11 @@ function WgImportSheet({
[content],
)
useEffect(() => {
if (!open) return
setServerId(defaultServerId ?? "")
}, [open, defaultServerId])
const [context, setContext] = useState({ open: false, defaultServerId })
if (context.open !== open || context.defaultServerId !== defaultServerId) {
setContext({ open, defaultServerId })
if (open) setServerId(defaultServerId ?? "")
}
function runPreview() {
setParseError(null)
@@ -73,6 +76,7 @@ function WgImportSheet({
<Sheet
open={open}
onOpenChange={(v) => {
if (busy) return
if (v) {
setServerId(defaultServerId ?? "")
} else {
@@ -84,7 +88,7 @@ function WgImportSheet({
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetContent showCloseButton={!busy} side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Импорт конфига WireGuard</SheetTitle>
<SheetDescription>
@@ -92,10 +96,11 @@ function WgImportSheet({
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<fieldset disabled={busy} aria-busy={busy} className="min-h-0 min-w-0 flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<FormField label="Сервер" required>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
aria-label="Сервер"
value={serverId}
onChange={(e) => setServerId(e.target.value)}
>
@@ -111,6 +116,7 @@ function WgImportSheet({
<FormField label="Формат">
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
aria-label="Формат"
value={format}
onChange={(e) => setFormat(e.target.value as "auto" | "rsc" | "conf")}
>
@@ -135,6 +141,7 @@ function WgImportSheet({
<textarea
className="min-h-40 w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs leading-relaxed outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
placeholder={"[Interface]\nPrivateKey = …\n…\n\nили\n\n/interface wireguard add …"}
aria-label="Конфигурация WireGuard"
value={content}
onChange={(e) => {
setContent(e.target.value)
@@ -148,7 +155,7 @@ function WgImportSheet({
Предпросмотр
</Button>
{parseError && <p className="text-sm text-destructive">{parseError}</p>}
{parseError && <p role="alert" className="text-sm text-destructive">{parseError}</p>}
{preview && (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm flex flex-col gap-2">
@@ -167,14 +174,17 @@ function WgImportSheet({
))}
</div>
)}
</div>
</fieldset>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-col gap-2 sm:flex-col">
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание результата импорта…</p>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="flex w-full gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
type="button"
className="flex-1"
disabled={!serverId || !content.trim() || busy}
onClick={() => void onImport({ serverId, content, format, dryRun: false })}
+2 -2
View File
@@ -80,7 +80,7 @@ flowchart LR
## Текущая точка продолжения
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.2 — удаление и подтверждение импорта WireGuard); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.3 — удаление и защищённый импорт 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.2. Далее — создание/импорт/добавление пира: контекст формы, ошибка и повтор, различие dry-run и применения; затем IPsec/GRE/VXLAN/Firewall/Filters.
1. Этап 9: карта разделов и схема сохранены; удаление, подтверждение и ожидание импорта WireGuard исправлены и проверены в 9.1–9.3. Далее — создание интерфейса/добавление пира: контекст формы, ошибка, повтор и подтверждение результата; затем IPsec/GRE/VXLAN/Firewall/Filters.
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
+9 -1
View File
@@ -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.2 — подтверждение удаления и импорта WireGuard |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.3 — удаление и защищённый импорт WireGuard |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 9.3 — ожидание результата импорта WireGuard (2026-09-29)
- Импорт защищён синхронной блокировкой повторного вызова. Во время запроса поля и кнопки недоступны, крестик скрыт, Escape и клик вне панели не закрывают форму и не очищают конфигурацию. После ответа блокировка снимается.
- Ошибка API сохраняется в форме через role=alert до повтора/нового открытия; ожидание объявляется через role=status. Поля сервера, формата и конфигурации получили доступные имена. Существующий Sheet/Base UI сохранён; прокрутка формы ограничена родительской высотой через min-h-0.
- Синхронизация исходного сервера перенесена из effect в условный сброс при смене контекста, без lint-исключений. Сетевые правила и контракт импорта не менялись.
- Все 15 WireGuard E2E прошли: прежние сценарии и новый тест двойного нажатия, блокировки полей/закрытия, ошибки 500 с сохранением данных, повтора и очистки сообщения при следующем открытии. Итоговые lint и TypeScript прошли; diff-check чистый. Production build и реальные RouterOS/JWT в этом блоке не проверялись.
- Получен ReUI checklist. Этап 9 остаётся открытым. Далее — создание интерфейса и добавление пира: защита повторов, сохранение формы при ошибке и подтверждение результата. Затем переключение/экспорт и остальные сетевые разделы по карте этапа 9.
## Блок 9.2 — подтверждение применения импорта WireGuard (2026-09-28)
- По прямому поручению пользователя продолжена работа сверх резерва 25%. Взята ограниченная проверка импорта без расширения форм и сетевых правил.
+5 -1
View File
@@ -1,6 +1,6 @@
# Этап 9: VPN и сетевые операции
Статус: начат 2026-09-28, выполнены блоки 9.1–9.2. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
Статус: начат 2026-09-28, выполнены блоки 9.1–9.3. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
## Схема и референсы принятого плана
@@ -49,3 +49,7 @@
## Блок 9.2 — достоверность ответа импорта
Ответ проверяется схемой contracts: режим dryRun соответствует запросу, применение подтверждается applied. Ошибка сохраняет текст и сервер. Один E2E с пятью последовательными ответами (403, dry-run, отсутствие applied, повреждённый ответ, успех), lint и TypeScript прошли. Серверный dry-run не добавлен в UI; предпросмотр остаётся локальным. Далее — создание/пиры, защита повторов и закрытия формы импорта во время запроса. Реальные сетевые операции не проверены.
## Блок 9.3 — ожидание импорта (2026-09-29)
Повторная отправка, закрытие и редактирование формы блокируются до ответа. Ошибка остаётся в форме, ожидание объявляется; добавлены доступные имена полей. Все 15 профильных E2E прошли, включая двойное нажатие, Escape/внешний клик, сохранение данных после 500 и повтор. Далее — создание интерфейса/пира и подтверждение их операций. Полный аудит сетевых разделов ещё не выполнен.
+44
View File
@@ -207,3 +207,47 @@ test("WireGuard: импорт сохраняет форму без подтве
await expect(page.getByText("Импортировано: wg-import-test (+0 пиров)", { exact: true })).toBeVisible()
expect(attempts).toBe(5)
})
test("WireGuard: импорт удерживает форму до ответа и разрешает повтор", async ({ page }) => {
await setup(page, "/wireguard?server=1")
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
const content = "/interface wireguard add name=wg-wait"
await page.route("**/api/wireguard/import", async route => {
expect(route.request().postDataJSON()).toEqual({ serverId: "1", content, format: "auto", dryRun: false })
attempts++
if (attempts === 1) {
await gate
return route.fulfill({ status: 500, json: { error: "Ошибка импорта тест" } })
}
return route.fulfill({ json: { dryRun: false, preview: { format: "rsc", interface: { name: "wg-wait" }, peers: [] }, applied: { interfaceName: "wg-wait", peersCreated: 0 } } })
})
await page.getByRole("button", { name: "Импорт", exact: true }).click()
const dialog = page.getByRole("dialog", { name: "Импорт конфига WireGuard" })
await dialog.getByRole("textbox", { name: "Конфигурация WireGuard" }).fill(content)
const submit = dialog.getByRole("button", { name: "Применить на роутер" })
await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(dialog.getByRole("status")).toContainText("Ожидание результата")
await expect(dialog.getByRole("textbox")).toBeDisabled()
await expect(dialog.getByRole("combobox", { name: "Сервер" })).toBeDisabled()
await expect(dialog.getByRole("combobox", { name: "Формат" })).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Отмена" })).toBeDisabled()
await expect(dialog.getByRole("button", { name: "Закрыть", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await page.mouse.click(10, 300)
await expect(dialog).toBeVisible()
expect(attempts).toBe(1)
release!()
await expect(dialog.getByRole("alert")).toHaveText("Ошибка импорта тест")
await expect(dialog.getByRole("textbox")).toHaveValue(content)
await expect(submit).toBeEnabled()
await submit.click()
await expect(dialog).toHaveCount(0)
expect(attempts).toBe(2)
await page.getByRole("button", { name: "Импорт", exact: true }).click()
await expect(dialog.getByRole("alert")).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})