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 [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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 и клик вне панели не закрывают форму и не очищают конфигурацию. После ответа блокировка снимается.
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user