fix(ui): подтверждать создание интерфейса и пира WireGuard
This commit is contained in:
1 parent
ec6ec52965
commit
8fff3d7fff
8 files changed
+166
-52
No files matched your search
@@ -192,7 +192,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [importOpen, setImportOpen] = 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 [importError, setImportError] = useState<string | null>(null)
|
||||||
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
||||||
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
|
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
|
||||||
@@ -400,10 +402,13 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
}, [displayServers, displayIfaces])
|
}, [displayServers, displayIfaces])
|
||||||
|
|
||||||
async function handleCreate(form: WgCreateFormState) {
|
async function handleCreate(form: WgCreateFormState) {
|
||||||
|
if (formLock.current) return
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
toast.info("Создание на роутер доступно только в live-режиме")
|
toast.info("Создание на роутер доступно только в live-режиме")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
formLock.current = true
|
||||||
|
setCreateError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const ep = parseEndpoint(form.peerEndpoint)
|
const ep = parseEndpoint(form.peerEndpoint)
|
||||||
@@ -433,8 +438,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка создания")
|
setCreateError(e instanceof Error ? e.message : "Ошибка создания")
|
||||||
} finally {
|
} finally {
|
||||||
|
formLock.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,12 +451,12 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
format: "auto" | "rsc" | "conf"
|
format: "auto" | "rsc" | "conf"
|
||||||
dryRun: boolean
|
dryRun: boolean
|
||||||
}) {
|
}) {
|
||||||
if (importLock.current) return
|
if (formLock.current) return
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
toast.info("Импорт на роутер доступен только в live-режиме")
|
toast.info("Импорт на роутер доступен только в live-режиме")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
importLock.current = true
|
formLock.current = true
|
||||||
setImportError(null)
|
setImportError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
@@ -474,7 +480,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setImportError(e instanceof Error ? e.message : "Ошибка импорта")
|
setImportError(e instanceof Error ? e.message : "Ошибка импорта")
|
||||||
} finally {
|
} finally {
|
||||||
importLock.current = false
|
formLock.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,11 +545,14 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddPeer(form: WgPeerFormState) {
|
async function handleAddPeer(form: WgPeerFormState) {
|
||||||
|
if (formLock.current) return
|
||||||
if (sourceUnavailable(peerIface?.serverId)) return
|
if (sourceUnavailable(peerIface?.serverId)) return
|
||||||
if (!isLive || !peerIface) {
|
if (!isLive || !peerIface) {
|
||||||
toast.info("Доступно только в live-режиме")
|
toast.info("Доступно только в live-режиме")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
formLock.current = true
|
||||||
|
setPeerError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const ep = parseEndpoint(form.endpoint)
|
const ep = parseEndpoint(form.endpoint)
|
||||||
@@ -564,8 +573,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
setPeerIface(null)
|
setPeerIface(null)
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка")
|
setPeerError(e instanceof Error ? e.message : "Ошибка")
|
||||||
} finally {
|
} finally {
|
||||||
|
formLock.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -605,7 +615,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
}
|
}
|
||||||
|
|
||||||
const emptyCreateAction = (
|
const emptyCreateAction = (
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => { setCreateError(null); setCreateOpen(true) }}>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Новый интерфейс
|
Новый интерфейс
|
||||||
</Button>
|
</Button>
|
||||||
@@ -657,7 +667,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
<UploadIcon className="size-4" />
|
<UploadIcon className="size-4" />
|
||||||
Импорт
|
Импорт
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => { setCreateError(null); setCreateOpen(true) }}>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Новый интерфейс
|
Новый интерфейс
|
||||||
</Button>
|
</Button>
|
||||||
@@ -776,7 +786,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
compactServer={compactServer}
|
compactServer={compactServer}
|
||||||
emptyAction={emptyCreateAction}
|
emptyAction={emptyCreateAction}
|
||||||
onExport={(iface) => openExport(iface, "rsc")}
|
onExport={(iface) => openExport(iface, "rsc")}
|
||||||
onAddPeer={selectedSourceError ? undefined : setPeerIface}
|
onAddPeer={selectedSourceError ? undefined : iface => { setPeerError(null); setPeerIface(iface) }}
|
||||||
onToggle={selectedSourceError ? undefined : handleToggle}
|
onToggle={selectedSourceError ? undefined : handleToggle}
|
||||||
onDelete={selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })}
|
onDelete={selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })}
|
||||||
onDeletePeer={selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
|
onDeletePeer={selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
|
||||||
@@ -798,7 +808,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
compactServer={compactServer}
|
compactServer={compactServer}
|
||||||
emptyAction={
|
emptyAction={
|
||||||
scopedIfaces.length === 1 ? (
|
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" />
|
<PlusIcon className="size-4" />
|
||||||
Добавить пира
|
Добавить пира
|
||||||
</Button>
|
</Button>
|
||||||
@@ -872,7 +882,8 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
|
|
||||||
<WgCreateSheet
|
<WgCreateSheet
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onOpenChange={setCreateOpen}
|
onOpenChange={v => { if (!formLock.current) setCreateOpen(v) }}
|
||||||
|
error={createError}
|
||||||
servers={serverOptions}
|
servers={serverOptions}
|
||||||
defaultServerId={sheetServerId}
|
defaultServerId={sheetServerId}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -880,7 +891,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
/>
|
/>
|
||||||
<WgImportSheet
|
<WgImportSheet
|
||||||
open={importOpen}
|
open={importOpen}
|
||||||
onOpenChange={v => { if (!importLock.current) setImportOpen(v) }}
|
onOpenChange={v => { if (!formLock.current) setImportOpen(v) }}
|
||||||
error={importError}
|
error={importError}
|
||||||
servers={serverOptions}
|
servers={serverOptions}
|
||||||
defaultServerId={sheetServerId}
|
defaultServerId={sheetServerId}
|
||||||
@@ -891,7 +902,8 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
|||||||
open={!!peerIface}
|
open={!!peerIface}
|
||||||
iface={peerIface}
|
iface={peerIface}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onOpenChange={(v) => { if (!v) setPeerIface(null) }}
|
onOpenChange={(v) => { if (!v && !formLock.current) setPeerIface(null) }}
|
||||||
|
error={peerError}
|
||||||
onSubmit={handleAddPeer}
|
onSubmit={handleAddPeer}
|
||||||
/>
|
/>
|
||||||
<WgExportSheet
|
<WgExportSheet
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
@@ -51,6 +52,7 @@ function WgCreateSheet({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
servers,
|
servers,
|
||||||
busy,
|
busy,
|
||||||
|
error,
|
||||||
defaultServerId,
|
defaultServerId,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
@@ -58,6 +60,7 @@ function WgCreateSheet({
|
|||||||
onOpenChange: (v: boolean) => void
|
onOpenChange: (v: boolean) => void
|
||||||
servers: ServerOption[]
|
servers: ServerOption[]
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
|
error?: string | null
|
||||||
defaultServerId?: string
|
defaultServerId?: string
|
||||||
onSubmit: (form: WgCreateFormState) => void | Promise<void>
|
onSubmit: (form: WgCreateFormState) => void | Promise<void>
|
||||||
}) {
|
}) {
|
||||||
@@ -65,18 +68,19 @@ function WgCreateSheet({
|
|||||||
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
|
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
|
||||||
setForm((f) => ({ ...f, [k]: v }))
|
setForm((f) => ({ ...f, [k]: v }))
|
||||||
|
|
||||||
useEffect(() => {
|
const [context, setContext] = useState({ open: false, defaultServerId })
|
||||||
if (!open) return
|
if (context.open !== open || context.defaultServerId !== defaultServerId) {
|
||||||
setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
|
setContext({ open, defaultServerId })
|
||||||
}, [open, defaultServerId])
|
if (open) setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
|
||||||
|
}
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
return Boolean(form.serverId && form.name.trim() && form.listenPort)
|
return Boolean(form.serverId && form.name.trim() && form.listenPort)
|
||||||
}, [form.serverId, form.name, form.listenPort])
|
}, [form.serverId, form.name, form.listenPort])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={v => { if (!busy) 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">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
|
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
@@ -84,12 +88,13 @@ function WgCreateSheet({
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</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">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<FormField label="Сервер" required>
|
<FormField label="Сервер" required>
|
||||||
<select
|
<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]"
|
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}
|
value={form.serverId}
|
||||||
onChange={(e) => set("serverId", e.target.value)}
|
onChange={(e) => set("serverId", e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -105,7 +110,7 @@ function WgCreateSheet({
|
|||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
placeholder="wg0"
|
placeholder="wg0"
|
||||||
value={form.name}
|
aria-label="Имя интерфейса" value={form.name}
|
||||||
onChange={(e) => set("name", e.target.value)}
|
onChange={(e) => set("name", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -113,21 +118,21 @@ function WgCreateSheet({
|
|||||||
<FormField label="Listen port" required>
|
<FormField label="Listen port" required>
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={form.listenPort}
|
aria-label="Listen port" value={form.listenPort}
|
||||||
onChange={(e) => set("listenPort", e.target.value)}
|
onChange={(e) => set("listenPort", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="MTU">
|
<FormField label="MTU">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={form.mtu}
|
aria-label="MTU" value={form.mtu}
|
||||||
onChange={(e) => set("mtu", e.target.value)}
|
onChange={(e) => set("mtu", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<FormField label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input
|
<Input
|
||||||
value={form.comment}
|
aria-label="Комментарий" value={form.comment}
|
||||||
onChange={(e) => set("comment", e.target.value)}
|
onChange={(e) => set("comment", e.target.value)}
|
||||||
placeholder="MSK → SPB overlay"
|
placeholder="MSK → SPB overlay"
|
||||||
/>
|
/>
|
||||||
@@ -137,7 +142,7 @@ function WgCreateSheet({
|
|||||||
<p className="text-sm font-medium">Включён</p>
|
<p className="text-sm font-medium">Включён</p>
|
||||||
<p className="text-xs text-muted-foreground">disabled=no на роутере</p>
|
<p className="text-xs text-muted-foreground">disabled=no на роутере</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -156,7 +161,7 @@ function WgCreateSheet({
|
|||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
placeholder="10.210.0.1/30"
|
placeholder="10.210.0.1/30"
|
||||||
value={form.address}
|
aria-label="IP на интерфейсе" value={form.address}
|
||||||
onChange={(e) => set("address", e.target.value)}
|
onChange={(e) => set("address", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -166,7 +171,7 @@ function WgCreateSheet({
|
|||||||
<p className="text-sm font-medium">Добавить первого пира</p>
|
<p className="text-sm font-medium">Добавить первого пира</p>
|
||||||
<p className="text-xs text-muted-foreground">Сразу после создания интерфейса</p>
|
<p className="text-xs text-muted-foreground">Сразу после создания интерфейса</p>
|
||||||
</div>
|
</div>
|
||||||
<FormToggle checked={form.peerEnabled} onChange={(v) => set("peerEnabled", v)} />
|
<Switch aria-label="Добавить первого пира" disabled={busy} checked={form.peerEnabled} onCheckedChange={(v) => set("peerEnabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{form.peerEnabled && (
|
{form.peerEnabled && (
|
||||||
@@ -174,7 +179,7 @@ function WgCreateSheet({
|
|||||||
<FormField label="Public key пира" required>
|
<FormField label="Public key пира" required>
|
||||||
<Input
|
<Input
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
value={form.peerPublicKey}
|
aria-label="Public key пира" value={form.peerPublicKey}
|
||||||
onChange={(e) => set("peerPublicKey", e.target.value)}
|
onChange={(e) => set("peerPublicKey", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -182,7 +187,7 @@ function WgCreateSheet({
|
|||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
placeholder="10.210.0.2/32"
|
placeholder="10.210.0.2/32"
|
||||||
value={form.peerAllowedIps}
|
aria-label="Allowed IPs пира" value={form.peerAllowedIps}
|
||||||
onChange={(e) => set("peerAllowedIps", e.target.value)}
|
onChange={(e) => set("peerAllowedIps", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -190,20 +195,20 @@ function WgCreateSheet({
|
|||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
placeholder="1.2.3.4:13231"
|
placeholder="1.2.3.4:13231"
|
||||||
value={form.peerEndpoint}
|
aria-label="Endpoint пира" value={form.peerEndpoint}
|
||||||
onChange={(e) => set("peerEndpoint", e.target.value)}
|
onChange={(e) => set("peerEndpoint", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Keepalive (сек)">
|
<FormField label="Keepalive (сек)">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={form.peerKeepalive}
|
aria-label="Keepalive пира" value={form.peerKeepalive}
|
||||||
onChange={(e) => set("peerKeepalive", e.target.value)}
|
onChange={(e) => set("peerKeepalive", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Комментарий пира">
|
<FormField label="Комментарий пира">
|
||||||
<Input
|
<Input
|
||||||
value={form.peerComment}
|
aria-label="Комментарий пира" value={form.peerComment}
|
||||||
onChange={(e) => set("peerComment", e.target.value)}
|
onChange={(e) => set("peerComment", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -211,13 +216,15 @@ function WgCreateSheet({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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 render={<Button variant="outline" className="flex-1" disabled={busy} />}>
|
||||||
Отмена
|
Отмена
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!canSubmit || busy}
|
disabled={!canSubmit || busy}
|
||||||
onClick={() => void onSubmit(form)}
|
onClick={() => void onSubmit(form)}
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ function WgPeerSheet({
|
|||||||
open,
|
open,
|
||||||
iface,
|
iface,
|
||||||
busy,
|
busy,
|
||||||
|
error,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
iface: WgIfaceWithServer | null
|
iface: WgIfaceWithServer | null
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
|
error?: string | null
|
||||||
onOpenChange: (v: boolean) => void
|
onOpenChange: (v: boolean) => void
|
||||||
onSubmit: (form: WgPeerFormState) => void | Promise<void>
|
onSubmit: (form: WgPeerFormState) => void | Promise<void>
|
||||||
}) {
|
}) {
|
||||||
@@ -43,27 +45,33 @@ function WgPeerSheet({
|
|||||||
const set = <K extends keyof WgPeerFormState>(k: K, v: WgPeerFormState[K]) =>
|
const set = <K extends keyof WgPeerFormState>(k: K, v: WgPeerFormState[K]) =>
|
||||||
setForm((f) => ({ ...f, [k]: v }))
|
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 (
|
return (
|
||||||
<Sheet
|
<Sheet
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(v) => {
|
onOpenChange={(v) => {
|
||||||
if (v) setForm(emptyPeerForm())
|
if (busy) return
|
||||||
onOpenChange(v)
|
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">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Добавить пира</SheetTitle>
|
<SheetTitle>Добавить пира</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
{iface ? `${iface.name} · ${iface.serverName}` : "WireGuard peer"}
|
{iface ? `${iface.name} · ${iface.serverName}` : "WireGuard peer"}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</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>
|
<SectionTitle>Параметры пира</SectionTitle>
|
||||||
<FormField label="Public key" required>
|
<FormField label="Public key" required>
|
||||||
<Input
|
<Input
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
value={form.publicKey}
|
aria-label="Public key" value={form.publicKey}
|
||||||
onChange={(e) => set("publicKey", e.target.value)}
|
onChange={(e) => set("publicKey", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -71,7 +79,7 @@ function WgPeerSheet({
|
|||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
placeholder="10.210.0.2/32"
|
placeholder="10.210.0.2/32"
|
||||||
value={form.allowedIps}
|
aria-label="Allowed IPs" value={form.allowedIps}
|
||||||
onChange={(e) => set("allowedIps", e.target.value)}
|
onChange={(e) => set("allowedIps", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -79,26 +87,29 @@ function WgPeerSheet({
|
|||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
placeholder="1.2.3.4:13231"
|
placeholder="1.2.3.4:13231"
|
||||||
value={form.endpoint}
|
aria-label="Endpoint" value={form.endpoint}
|
||||||
onChange={(e) => set("endpoint", e.target.value)}
|
onChange={(e) => set("endpoint", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Keepalive (сек)">
|
<FormField label="Keepalive (сек)">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={form.keepalive}
|
aria-label="Keepalive (сек)" value={form.keepalive}
|
||||||
onChange={(e) => set("keepalive", e.target.value)}
|
onChange={(e) => set("keepalive", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Комментарий">
|
<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>
|
</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">
|
<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 render={<Button variant="outline" className="flex-1" disabled={busy} />}>
|
||||||
Отмена
|
Отмена
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={busy || !form.publicKey.trim() || !form.allowedIps.trim()}
|
disabled={busy || !form.publicKey.trim() || !form.allowedIps.trim()}
|
||||||
onClick={() => void onSubmit(form)}
|
onClick={() => void onSubmit(form)}
|
||||||
|
|||||||
+2
-2
@@ -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).
|
Подробные планы: [этап 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; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -25,12 +25,21 @@
|
|||||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 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 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 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)
|
## Блок 9.3 — ожидание результата импорта WireGuard (2026-09-29)
|
||||||
|
|
||||||
- Импорт защищён синхронной блокировкой повторного вызова. Во время запроса поля и кнопки недоступны, крестик скрыт, Escape и клик вне панели не закрывают форму и не очищают конфигурацию. После ответа блокировка снимается.
|
- Импорт защищён синхронной блокировкой повторного вызова. Во время запроса поля и кнопки недоступны, крестик скрыт, Escape и клик вне панели не закрывают форму и не очищают конфигурацию. После ответа блокировка снимается.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Этап 9: VPN и сетевые операции
|
# Этап 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)
|
## Блок 9.3 — ожидание импорта (2026-09-29)
|
||||||
|
|
||||||
Повторная отправка, закрытие и редактирование формы блокируются до ответа. Ошибка остаётся в форме, ожидание объявляется; добавлены доступные имена полей. Все 15 профильных E2E прошли, включая двойное нажатие, Escape/внешний клик, сохранение данных после 500 и повтор. Далее — создание интерфейса/пира и подтверждение их операций. Полный аудит сетевых разделов ещё не выполнен.
|
Повторная отправка, закрытие и редактирование формы блокируются до ответа. Ошибка остаётся в форме, ожидание объявляется; добавлены доступные имена полей. Все 15 профильных E2E прошли, включая двойное нажатие, Escape/внешний клик, сохранение данных после 500 и повтор. Далее — создание интерфейса/пира и подтверждение их операций. Полный аудит сетевых разделов ещё не выполнен.
|
||||||
|
|
||||||
|
## Блок 9.4 — создание интерфейса и пира (2026-09-29)
|
||||||
|
|
||||||
|
Формы блокируют повтор/закрытие/изменение до ответа и сохраняют ввод при ошибке. Проверяются оба backend-ответа создания интерфейса (DTO либо ok/name) и ok=true создания пира. Поля и переключатели получили доступные имена; повторное открытие пира сбрасывает ввод. Все 18 E2E прошли, три новых повторены после уточнения переключателей; lint и TypeScript прошли. Итог сборки — в журнале. Следующие проверки: переключение интерфейса и экспорт с защитой контекста. Реальный RouterOS/JWT и транзакционность составной операции не подтверждены локальными моками.
|
||||||
+11
-3
@@ -1,4 +1,4 @@
|
|||||||
import { wgImportResponseSchema } from "@mmapp/contracts/wireguard"
|
import { wgIfaceDtoSchema, wgImportResponseSchema } from "@mmapp/contracts/wireguard"
|
||||||
import type {
|
import type {
|
||||||
WgCreateInterface,
|
WgCreateInterface,
|
||||||
WgCreatePeerRequest,
|
WgCreatePeerRequest,
|
||||||
@@ -27,10 +27,16 @@ export async function createWireGuardInterface(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
payload: WgCreateInterface,
|
payload: WgCreateInterface,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return requestJson(baseUrl, "/api/wireguard/interfaces", {
|
const result = await requestJson<unknown>(baseUrl, "/api/wireguard/interfaces", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
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(
|
export async function patchWireGuardInterface(
|
||||||
@@ -59,10 +65,12 @@ export async function createWireGuardPeer(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
payload: WgCreatePeerRequest,
|
payload: WgCreatePeerRequest,
|
||||||
): Promise<{ ok: boolean }> {
|
): Promise<{ ok: boolean }> {
|
||||||
return requestJson(baseUrl, "/api/wireguard/peers", {
|
const result = await requestJson<{ ok: boolean }>(baseUrl, "/api/wireguard/peers", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
if (result?.ok !== true) throw new Error("Сервер не подтвердил создание пира")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function patchWireGuardPeer(
|
export async function patchWireGuardPeer(
|
||||||
|
|||||||
@@ -251,3 +251,66 @@ test("WireGuard: импорт удерживает форму до ответа
|
|||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
await expect(dialog).toHaveCount(0)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user