fix(ui): защитить форму импорта WireGuard во время запроса
This commit is contained in:
1 parent
efc6da1249
commit
ec6ec52965
6 files changed
+89
-16
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 — остаются явно зафиксированными, без заявления о реализации.
|
||||
|
||||
|
||||
@@ -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%. Взята ограниченная проверка импорта без расширения форм и сетевых правил.
|
||||
|
||||
@@ -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 и повтор. Далее — создание интерфейса/пира и подтверждение их операций. Полный аудит сетевых разделов ещё не выполнен.
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user