fix(ui): подтверждать создание интерфейса и пира WireGuard

This commit is contained in:
Denozordec committed 2026-09-29 08:30:07 +07:00
1 parent ec6ec52965
commit 8fff3d7fff
8 files changed
+166 -52

No files matched your search

+25 -13
View File
@@ -192,7 +192,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const importLock = useRef(false)
const formLock = useRef(false)
const [createError, setCreateError] = useState<string | null>(null)
const [peerError, setPeerError] = useState<string | null>(null)
const [importError, setImportError] = useState<string | null>(null)
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
@@ -400,10 +402,13 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
}, [displayServers, displayIfaces])
async function handleCreate(form: WgCreateFormState) {
if (formLock.current) return
if (!isLive) {
toast.info("Создание на роутер доступно только в live-режиме")
return
}
formLock.current = true
setCreateError(null)
setBusy(true)
try {
const ep = parseEndpoint(form.peerEndpoint)
@@ -433,8 +438,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
setCreateOpen(false)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка создания")
setCreateError(e instanceof Error ? e.message : "Ошибка создания")
} finally {
formLock.current = false
setBusy(false)
}
}
@@ -445,12 +451,12 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
format: "auto" | "rsc" | "conf"
dryRun: boolean
}) {
if (importLock.current) return
if (formLock.current) return
if (!isLive) {
toast.info("Импорт на роутер доступен только в live-режиме")
return
}
importLock.current = true
formLock.current = true
setImportError(null)
setBusy(true)
try {
@@ -474,7 +480,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
} catch (e) {
setImportError(e instanceof Error ? e.message : "Ошибка импорта")
} finally {
importLock.current = false
formLock.current = false
setBusy(false)
}
}
@@ -539,11 +545,14 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
}
async function handleAddPeer(form: WgPeerFormState) {
if (formLock.current) return
if (sourceUnavailable(peerIface?.serverId)) return
if (!isLive || !peerIface) {
toast.info("Доступно только в live-режиме")
return
}
formLock.current = true
setPeerError(null)
setBusy(true)
try {
const ep = parseEndpoint(form.endpoint)
@@ -564,8 +573,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
setPeerIface(null)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка")
setPeerError(e instanceof Error ? e.message : "Ошибка")
} finally {
formLock.current = false
setBusy(false)
}
}
@@ -605,7 +615,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
}
const emptyCreateAction = (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Button size="sm" onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
@@ -657,7 +667,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Button size="sm" onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
@@ -776,7 +786,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
compactServer={compactServer}
emptyAction={emptyCreateAction}
onExport={(iface) => openExport(iface, "rsc")}
onAddPeer={selectedSourceError ? undefined : setPeerIface}
onAddPeer={selectedSourceError ? undefined : iface => { setPeerError(null); setPeerIface(iface) }}
onToggle={selectedSourceError ? undefined : handleToggle}
onDelete={selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })}
onDeletePeer={selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
@@ -798,7 +808,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
compactServer={compactServer}
emptyAction={
scopedIfaces.length === 1 ? (
<Button size="sm" disabled={selectedSourceError} onClick={() => setPeerIface(scopedIfaces[0])}>
<Button size="sm" disabled={selectedSourceError} onClick={() => { setPeerError(null); setPeerIface(scopedIfaces[0]) }}>
<PlusIcon className="size-4" />
Добавить пира
</Button>
@@ -872,7 +882,8 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
<WgCreateSheet
open={createOpen}
onOpenChange={setCreateOpen}
onOpenChange={v => { if (!formLock.current) setCreateOpen(v) }}
error={createError}
servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy}
@@ -880,7 +891,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
/>
<WgImportSheet
open={importOpen}
onOpenChange={v => { if (!importLock.current) setImportOpen(v) }}
onOpenChange={v => { if (!formLock.current) setImportOpen(v) }}
error={importError}
servers={serverOptions}
defaultServerId={sheetServerId}
@@ -891,7 +902,8 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
open={!!peerIface}
iface={peerIface}
busy={busy}
onOpenChange={(v) => { if (!v) setPeerIface(null) }}
onOpenChange={(v) => { if (!v && !formLock.current) setPeerIface(null) }}
error={peerError}
onSubmit={handleAddPeer}
/>
<WgExportSheet
+30 -23
View File
@@ -1,9 +1,10 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { useMemo, useState } from "react"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
@@ -51,6 +52,7 @@ function WgCreateSheet({
onOpenChange,
servers,
busy,
error,
defaultServerId,
onSubmit,
}: {
@@ -58,6 +60,7 @@ function WgCreateSheet({
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
error?: string | null
defaultServerId?: string
onSubmit: (form: WgCreateFormState) => void | Promise<void>
}) {
@@ -65,18 +68,19 @@ function WgCreateSheet({
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open) return
setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
}, [open, defaultServerId])
const [context, setContext] = useState({ open: false, defaultServerId })
if (context.open !== open || context.defaultServerId !== defaultServerId) {
setContext({ open, defaultServerId })
if (open) setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
}
const canSubmit = useMemo(() => {
return Boolean(form.serverId && form.name.trim() && form.listenPort)
}, [form.serverId, form.name, form.listenPort])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<Sheet open={open} onOpenChange={v => { if (!busy) onOpenChange(v) }}>
<SheetContent showCloseButton={!busy} side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
<SheetDescription>
@@ -84,12 +88,13 @@ function WgCreateSheet({
</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">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<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={form.serverId}
onChange={(e) => set("serverId", e.target.value)}
>
@@ -105,7 +110,7 @@ function WgCreateSheet({
<Input
className="font-mono"
placeholder="wg0"
value={form.name}
aria-label="Имя интерфейса" value={form.name}
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
@@ -113,21 +118,21 @@ function WgCreateSheet({
<FormField label="Listen port" required>
<Input
className="font-mono"
value={form.listenPort}
aria-label="Listen port" value={form.listenPort}
onChange={(e) => set("listenPort", e.target.value)}
/>
</FormField>
<FormField label="MTU">
<Input
className="font-mono"
value={form.mtu}
aria-label="MTU" value={form.mtu}
onChange={(e) => set("mtu", e.target.value)}
/>
</FormField>
</div>
<FormField label="Комментарий">
<Input
value={form.comment}
aria-label="Комментарий" value={form.comment}
onChange={(e) => set("comment", e.target.value)}
placeholder="MSK → SPB overlay"
/>
@@ -137,7 +142,7 @@ function WgCreateSheet({
<p className="text-sm font-medium">Включён</p>
<p className="text-xs text-muted-foreground">disabled=no на роутере</p>
</div>
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
<Switch aria-label="Интерфейс включён" disabled={busy} checked={form.enabled} onCheckedChange={(v) => set("enabled", v)} />
</div>
</div>
@@ -156,7 +161,7 @@ function WgCreateSheet({
<Input
className="font-mono"
placeholder="10.210.0.1/30"
value={form.address}
aria-label="IP на интерфейсе" value={form.address}
onChange={(e) => set("address", e.target.value)}
/>
</FormField>
@@ -166,7 +171,7 @@ function WgCreateSheet({
<p className="text-sm font-medium">Добавить первого пира</p>
<p className="text-xs text-muted-foreground">Сразу после создания интерфейса</p>
</div>
<FormToggle checked={form.peerEnabled} onChange={(v) => set("peerEnabled", v)} />
<Switch aria-label="Добавить первого пира" disabled={busy} checked={form.peerEnabled} onCheckedChange={(v) => set("peerEnabled", v)} />
</div>
{form.peerEnabled && (
@@ -174,7 +179,7 @@ function WgCreateSheet({
<FormField label="Public key пира" required>
<Input
className="font-mono text-xs"
value={form.peerPublicKey}
aria-label="Public key пира" value={form.peerPublicKey}
onChange={(e) => set("peerPublicKey", e.target.value)}
/>
</FormField>
@@ -182,7 +187,7 @@ function WgCreateSheet({
<Input
className="font-mono"
placeholder="10.210.0.2/32"
value={form.peerAllowedIps}
aria-label="Allowed IPs пира" value={form.peerAllowedIps}
onChange={(e) => set("peerAllowedIps", e.target.value)}
/>
</FormField>
@@ -190,20 +195,20 @@ function WgCreateSheet({
<Input
className="font-mono"
placeholder="1.2.3.4:13231"
value={form.peerEndpoint}
aria-label="Endpoint пира" value={form.peerEndpoint}
onChange={(e) => set("peerEndpoint", e.target.value)}
/>
</FormField>
<FormField label="Keepalive (сек)">
<Input
className="font-mono"
value={form.peerKeepalive}
aria-label="Keepalive пира" value={form.peerKeepalive}
onChange={(e) => set("peerKeepalive", e.target.value)}
/>
</FormField>
<FormField label="Комментарий пира">
<Input
value={form.peerComment}
aria-label="Комментарий пира" value={form.peerComment}
onChange={(e) => set("peerComment", e.target.value)}
/>
</FormField>
@@ -211,13 +216,15 @@ function WgCreateSheet({
)}
</div>
)}
</div>
</fieldset>
{busy && <p role="status" className="px-6 text-sm text-muted-foreground">Ожидание подтверждения создания…</p>}
{error && <p role="alert" className="px-6 text-sm text-destructive">{error}</p>}
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
type="button"
className="flex-1"
disabled={!canSubmit || busy}
onClick={() => void onSubmit(form)}
+20 -9
View File
@@ -30,12 +30,14 @@ function WgPeerSheet({
open,
iface,
busy,
error,
onOpenChange,
onSubmit,
}: {
open: boolean
iface: WgIfaceWithServer | null
busy?: boolean
error?: string | null
onOpenChange: (v: boolean) => void
onSubmit: (form: WgPeerFormState) => void | Promise<void>
}) {
@@ -43,27 +45,33 @@ function WgPeerSheet({
const set = <K extends keyof WgPeerFormState>(k: K, v: WgPeerFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
const [context, setContext] = useState({ open: false, iface })
if (context.open !== open || context.iface !== iface) {
setContext({ open, iface })
if (open) setForm(emptyPeerForm())
}
return (
<Sheet
open={open}
onOpenChange={(v) => {
if (v) setForm(emptyPeerForm())
if (busy) return
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetContent showCloseButton={!busy} side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Добавить пира</SheetTitle>
<SheetDescription>
{iface ? `${iface.name} · ${iface.serverName}` : "WireGuard peer"}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4">
<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-4">
<SectionTitle>Параметры пира</SectionTitle>
<FormField label="Public key" required>
<Input
className="font-mono text-xs"
value={form.publicKey}
aria-label="Public key" value={form.publicKey}
onChange={(e) => set("publicKey", e.target.value)}
/>
</FormField>
@@ -71,7 +79,7 @@ function WgPeerSheet({
<Input
className="font-mono"
placeholder="10.210.0.2/32"
value={form.allowedIps}
aria-label="Allowed IPs" value={form.allowedIps}
onChange={(e) => set("allowedIps", e.target.value)}
/>
</FormField>
@@ -79,26 +87,29 @@ function WgPeerSheet({
<Input
className="font-mono"
placeholder="1.2.3.4:13231"
value={form.endpoint}
aria-label="Endpoint" value={form.endpoint}
onChange={(e) => set("endpoint", e.target.value)}
/>
</FormField>
<FormField label="Keepalive (сек)">
<Input
className="font-mono"
value={form.keepalive}
aria-label="Keepalive (сек)" value={form.keepalive}
onChange={(e) => set("keepalive", e.target.value)}
/>
</FormField>
<FormField label="Комментарий">
<Input value={form.comment} onChange={(e) => set("comment", e.target.value)} />
<Input aria-label="Комментарий" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</FormField>
</div>
</fieldset>
{busy && <p role="status" className="px-6 text-sm text-muted-foreground">Ожидание подтверждения создания…</p>}
{error && <p role="alert" className="px-6 text-sm text-destructive">{error}</p>}
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
type="button"
className="flex-1"
disabled={busy || !form.publicKey.trim() || !form.allowedIps.trim()}
onClick={() => void onSubmit(form)}
+2 -2
View File
@@ -80,7 +80,7 @@ flowchart LR
## Текущая точка продолжения
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.3 — удаление и защищённый импорт WireGuard); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.4 — удаление, импорт и создание 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.3. Далее — создание интерфейса/добавление пира: контекст формы, ошибка, повтор и подтверждение результата; затем IPsec/GRE/VXLAN/Firewall/Filters.
1. Этап 9: карта разделов и схема сохранены; удаление, импорт и создание интерфейса/пира WireGuard исправлены и проверены в 9.1–9.4. Далее — переключение интерфейсов и экспорт: повтор, подтверждение результата и контекст позднего ответа; затем 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.1–9.3 — удаление и защищённый импорт WireGuard |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.4 — удаление, импорт и создание WireGuard |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 9.4 — создание интерфейса и пира WireGuard (2026-09-29)
- Создание интерфейса/пира и импорт используют общий синхронный замок формы. Повторный вызов не отправляет второй запрос; поля, переключатели, отмена и закрытие заблокированы до ответа. Ошибка остаётся внутри формы через role=alert, ожидание — role=status; значения сохраняются для повтора.
- Подтверждение интерфейса проверяется по реальному backend-контракту: DTO существующей схемы с совпадающими именем/сервером либо ok=true с совпадающим именем. Создание пира требует ok=true. Повреждённый HTTP 200 не объявляется успешным созданием. Правила RouterOS не менялись.
- Новое открытие формы пира сбрасывает прежний ввод; обе формы получили доступные имена полей, переключатели создания — aria-label. Сохранены установленные Sheet/Switch/Base UI и semantic tokens; область прокрутки использует min-h-0. Получен ReUI checklist.
- Все 18 WireGuard E2E прошли, включая три новых сценария создания (DTO интерфейса, короткое подтверждение интерфейса, пир). После изменения переключателей эти три сценария повторены: двойное нажатие, ожидание, Escape/внешний клик, 403, повреждённый ответ, сохранение ввода, успешный повтор и чистое повторное открытие. Итоговые lint, TypeScript и production build прошли (34 страницы), diff-check чистый.
- Ограничения: реальные RouterOS/JWT не проверены; 403 в HTTP-моке проверяет клиентскую реакцию. Многошаговое создание на RouterOS не стало транзакционным: частичный результат при серверной ошибке требует проверки устройства. Этап 9 открыт.
- Далее — переключение интерфейсов и экспорт WireGuard (повторы, подтверждение результата, контекст позднего ответа), затем остальные сетевые разделы. Локальный коммит без push.
## Блок 9.3 — ожидание результата импорта WireGuard (2026-09-29)
- Импорт защищён синхронной блокировкой повторного вызова. Во время запроса поля и кнопки недоступны, крестик скрыт, Escape и клик вне панели не закрывают форму и не очищают конфигурацию. После ответа блокировка снимается.
+5 -1
View File
@@ -1,6 +1,6 @@
# Этап 9: VPN и сетевые операции
Статус: начат 2026-09-28, выполнены блоки 9.1–9.3. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
Статус: начат 2026-09-28, выполнены блоки 9.1–9.4. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
## Схема и референсы принятого плана
@@ -53,3 +53,7 @@
## Блок 9.3 — ожидание импорта (2026-09-29)
Повторная отправка, закрытие и редактирование формы блокируются до ответа. Ошибка остаётся в форме, ожидание объявляется; добавлены доступные имена полей. Все 15 профильных E2E прошли, включая двойное нажатие, Escape/внешний клик, сохранение данных после 500 и повтор. Далее — создание интерфейса/пира и подтверждение их операций. Полный аудит сетевых разделов ещё не выполнен.
## Блок 9.4 — создание интерфейса и пира (2026-09-29)
Формы блокируют повтор/закрытие/изменение до ответа и сохраняют ввод при ошибке. Проверяются оба backend-ответа создания интерфейса (DTO либо ok/name) и ok=true создания пира. Поля и переключатели получили доступные имена; повторное открытие пира сбрасывает ввод. Все 18 E2E прошли, три новых повторены после уточнения переключателей; lint и TypeScript прошли. Итог сборки — в журнале. Следующие проверки: переключение интерфейса и экспорт с защитой контекста. Реальный RouterOS/JWT и транзакционность составной операции не подтверждены локальными моками.
+11 -3
View File
@@ -1,4 +1,4 @@
import { wgImportResponseSchema } from "@mmapp/contracts/wireguard"
import { wgIfaceDtoSchema, wgImportResponseSchema } from "@mmapp/contracts/wireguard"
import type {
WgCreateInterface,
WgCreatePeerRequest,
@@ -27,10 +27,16 @@ export async function createWireGuardInterface(
baseUrl: string,
payload: WgCreateInterface,
): Promise<unknown> {
return requestJson(baseUrl, "/api/wireguard/interfaces", {
const result = await requestJson<unknown>(baseUrl, "/api/wireguard/interfaces", {
method: "POST",
body: JSON.stringify(payload),
})
const iface = wgIfaceDtoSchema.safeParse(result)
const confirmed = iface.success
? iface.data.name === payload.name && String(iface.data.serverId) === String(payload.serverId)
: typeof result === "object" && result !== null && "ok" in result && result.ok === true && "name" in result && result.name === payload.name
if (!confirmed) throw new Error("Сервер не подтвердил создание интерфейса")
return result
}
export async function patchWireGuardInterface(
@@ -59,10 +65,12 @@ export async function createWireGuardPeer(
baseUrl: string,
payload: WgCreatePeerRequest,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, "/api/wireguard/peers", {
const result = await requestJson<{ ok: boolean }>(baseUrl, "/api/wireguard/peers", {
method: "POST",
body: JSON.stringify(payload),
})
if (result?.ok !== true) throw new Error("Сервер не подтвердил создание пира")
return result
}
export async function patchWireGuardPeer(
+63
View File
@@ -251,3 +251,66 @@ test("WireGuard: импорт удерживает форму до ответа
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
for (const kind of ["interface", "interface-dto", "peer"] as const) {
test("WireGuard: создание сохраняет форму и ждёт подтверждения " + kind, async ({ page }) => {
await setup(page, "/wireguard?server=1&interface=*%2F2")
const peer = kind === "peer"
let attempts = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
await page.route("**/api/wireguard/" + (peer ? "peers" : "interfaces"), async route => {
const body = route.request().postDataJSON()
expect(body.serverId).toBe("1")
expect(body[peer ? "interfaceName" : "name"]).toBe(peer ? "wg-same" : "wg-new")
if (peer) {
expect(body.publicKey).toBe("fixture-public-key")
expect(body.allowedAddresses).toEqual(["10.0.3.1/32"])
}
attempts++
if (attempts === 1) {
await gate
return route.fulfill({ status: 403, json: { error: "Создание запрещено тест" } })
}
if (attempts === 2) return route.fulfill({ json: {} })
const dto = wgListResponseSchema.parse({ interfaces: [{ id: "new", rosId: "*N", serverId: "1", serverName: "Источник", name: "wg-new", listenPort: 13231, mtu: 1420, comment: "", enabled: true, status: "down", peers: [] }] }).interfaces[0]
return route.fulfill({ status: 201, json: kind === "interface-dto" ? dto : peer ? { ok: true } : { ok: true, name: "wg-new" } })
})
const open = async () => {
if (peer) {
await page.getByRole("button", { name: "Действия: wg-same" }).click()
await page.getByRole("menuitem", { name: "Добавить пира" }).click()
} else await page.getByRole("button", { name: "Новый интерфейс", exact: true }).click()
}
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await open()
const dialog = page.getByRole("dialog", { name: peer ? "Добавить пира" : "Быстрый туннель WireGuard" })
const input = dialog.getByRole("textbox", { name: peer ? "Public key" : "Имя интерфейса", exact: true })
await input.fill(peer ? "fixture-public-key" : "wg-new")
if (peer) await dialog.getByRole("textbox", { name: "Allowed IPs", exact: true }).fill("10.0.3.1/32")
const submit = dialog.getByRole("button", { name: peer ? "Добавить" : "Создать туннель", exact: true })
await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1)
await expect(input).toBeDisabled()
if (!peer) await expect(dialog.getByRole("switch", { name: "Интерфейс включён" })).toBeDisabled()
await expect(dialog.getByRole("status")).toContainText("Ожидание подтверждения")
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()
release!()
await expect(dialog.getByRole("alert")).toHaveText("Создание запрещено тест")
await expect(input).toHaveValue(peer ? "fixture-public-key" : "wg-new")
await submit.click()
await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил создание")
await submit.click()
await expect(dialog).toHaveCount(0)
expect(attempts).toBe(3)
await open()
await expect(dialog.getByRole("alert")).toHaveCount(0)
await expect(input).toHaveValue("")
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
}