fix(ui): подтверждать инициализацию IPsec и сохранять форму
This commit is contained in:
1 parent
4a8d696e4e
commit
f9413d5100
7 files changed
+144
-27
No files matched your search
@@ -370,10 +370,6 @@ export default function IpsecPage() {
|
||||
toast.success("IKEv2-сервер инициализирован")
|
||||
setInitOpen(false)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error("Ошибка инициализации", {
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
@@ -45,31 +46,58 @@ function IpsecInitSheet({
|
||||
onSubmit: (form: IpsecInitFormState) => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<IpsecInitFormState>(defaultIpsecInitForm)
|
||||
const submittingRef = useRef(false)
|
||||
const openedRef = useRef(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pending = busy || submitting
|
||||
const set = <K extends keyof IpsecInitFormState>(k: K, v: IpsecInitFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!open) { openedRef.current = false; return }
|
||||
if (openedRef.current) return
|
||||
openedRef.current = true
|
||||
const server = servers.find((s) => s.id === (defaultServerId ?? ""))
|
||||
queueMicrotask(() => setForm({
|
||||
...defaultIpsecInitForm(),
|
||||
serverId: defaultServerId ?? "",
|
||||
serverEndpoint: server?.host ?? "",
|
||||
}))
|
||||
queueMicrotask(() => {
|
||||
setError(null)
|
||||
setForm({
|
||||
...defaultIpsecInitForm(),
|
||||
serverId: defaultServerId ?? "",
|
||||
serverEndpoint: server?.host ?? "",
|
||||
})
|
||||
})
|
||||
}, [open, defaultServerId, servers])
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return Boolean(
|
||||
form.serverId &&
|
||||
form.serverEndpoint.trim() &&
|
||||
/^(\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}|[\w.-]+\.[a-z]{2,})$/i.test(form.serverEndpoint.trim()) &&
|
||||
(/^(?:\d{1,3}\.){3}\d{1,3}$/.test(form.serverEndpoint.trim())
|
||||
? form.serverEndpoint.trim().split(".").every(part => Number(part) <= 255)
|
||||
: /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(form.serverEndpoint.trim())) &&
|
||||
/^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/.test(form.poolCidr.trim()),
|
||||
)
|
||||
}, [form])
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || busy || submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onSubmit(form)
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "Не удалось инициализировать сервер")
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
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={value => { if (!submittingRef.current && !busy) onOpenChange(value) }}>
|
||||
<SheetContent showCloseButton={!pending} 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>Инициализация IKEv2-сервера</SheetTitle>
|
||||
<SheetDescription>
|
||||
@@ -87,10 +115,11 @@ function IpsecInitSheet({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<fieldset disabled={pending} className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<FormField label="Сервер" required>
|
||||
<select
|
||||
aria-label="Сервер"
|
||||
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]"
|
||||
value={form.serverId}
|
||||
onChange={(e) => {
|
||||
@@ -111,6 +140,7 @@ function IpsecInitSheet({
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="vpn.example.com"
|
||||
aria-label="Адрес сервера (CN/SAN)"
|
||||
value={form.serverEndpoint}
|
||||
onChange={(e) => set("serverEndpoint", e.target.value)}
|
||||
/>
|
||||
@@ -119,6 +149,7 @@ function IpsecInitSheet({
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="10.77.0.0/24"
|
||||
aria-label="Подсеть клиентов"
|
||||
value={form.poolCidr}
|
||||
onChange={(e) => set("poolCidr", e.target.value)}
|
||||
/>
|
||||
@@ -126,6 +157,7 @@ function IpsecInitSheet({
|
||||
<FormField label="DNS для клиентов" hint="Например 10.77.0.1 или 1.1.1.1">
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="DNS для клиентов"
|
||||
value={form.dns}
|
||||
onChange={(e) => set("dns", e.target.value)}
|
||||
/>
|
||||
@@ -137,21 +169,23 @@ function IpsecInitSheet({
|
||||
Managed srcnat masquerade для подсети клиентов (out-interface-list WAN)
|
||||
</p>
|
||||
</div>
|
||||
<FormToggle checked={form.createNatRule} onChange={(v) => set("createNatRule", v)} />
|
||||
<Switch aria-label="Интернет клиентам (NAT)" disabled={pending} checked={form.createNatRule} onCheckedChange={(v) => set("createNatRule", v)} />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{pending && <p role="status" className="text-sm text-muted-foreground">Ожидание результата инициализации…</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<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={pending} />}>
|
||||
Отмена
|
||||
</SheetClose>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!canSubmit || busy}
|
||||
onClick={() => void onSubmit(form)}
|
||||
disabled={!canSubmit || pending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{busy ? "Инициализация…" : "Инициализировать"}
|
||||
{pending ? "Инициализация…" : "Инициализировать"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
+2
-2
@@ -80,7 +80,7 @@ flowchart LR
|
||||
|
||||
## Текущая точка продолжения
|
||||
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.13 — история всех шести разделов и восстановление); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.14 — история, восстановление и инициализация IPsec); этапы 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.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. Далее — инициализация IPsec: подтверждение API, сохранение формы и защита повторной отправки; затем пользователи/сертификаты и GRE/VXLAN/Firewall/Filters.
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. Далее — создание/редактирование пользователей 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.13 — история всех шести разделов и восстановление |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.14 — история, восстановление и инициализация IPsec |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.14 — инициализация IPsec (2026-09-29)
|
||||
|
||||
- Успех требует валидной сводки по contracts, совпадения serverId и initialized=true. Пустой ответ, неинициализированный сервер и чужое устройство не закрывают форму и не показывают успех. Backend возвращает сводку после применения; правила сетевой операции не изменялись.
|
||||
- Форма сохраняет ввод и показывает ошибку внутри панели. Синхронный замок предотвращает повтор; до ответа заблокированы поля, NAT, отмена, Escape и внешний клик. Ожидание объявляется через status. Инициализация полей выполняется один раз на открытие, чтобы обновление списка серверов не стирало ввод.
|
||||
- Исправлен ввод IPv4 без CIDR вместо ошибочного требования маски; домены сохранены, IPv4 с октетом больше 255 отклоняется. Поля и переключатель получили доступные имена. Полная валидация подсети остаётся на backend.
|
||||
- Прошли новый E2E и 3 регрессионных теста таблиц IPsec/GRE, lint всех изменённых файлов и TypeScript; diff-check чистый. Первоначальный тест исправлен: перед кнопкой инициализации требуется вкладка «Сервер». Build не повторялся; последний успешный — блок 9.13.
|
||||
- Следующий шаг: создание/редактирование пользователя IPsec — проверка контракта результата, сохранение формы, защита повторов; затем сертификаты и остальные разделы карты. Реальные RouterOS/JWT и транзакционность составной инициализации не проверены. Этап 9 открыт.
|
||||
- Использовано 41% пятичасовой и 84% недельной квоты. Локальный коммит без push; изменения пользователя в skill/config/AGENTS сохранены.
|
||||
|
||||
## Блок 9.13 — общая проверка и загрузка истории (2026-09-29)
|
||||
|
||||
- GRE, Firewall, Filters и рекурсивные маршруты подключены к ошибке/повтору общей панели. Ошибка не обнуляет сохранённые ревизии и не маскируется пустым списком. Поколение запроса защищает результат, ошибку и finally после закрытия, смены контекста и размонтирования; физическая отмена запросов не добавлялась.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Этап 9: VPN и сетевые операции
|
||||
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.13. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.14. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
|
||||
## Схема и референсы принятого плана
|
||||
|
||||
@@ -93,3 +93,7 @@ IPsec/GRE/Firewall/Filters/рекурсивные маршруты требую
|
||||
## Блок 9.13 — история всех разделов (2026-09-29)
|
||||
|
||||
Ошибка/повтор и защита поздних ответов подключены к GRE/Firewall/Filters/рекурсивным маршрутам. Общая схема проверяет записи, сервер и раздел во всех шести потребителях, включая IPsec. Все 36 E2E истории/WireGuard и TypeScript прошли; исходный lint страниц не изменился, новые общие файлы и тест чистые. Результат build — в журнале. Далее — форма инициализации IPsec (handleInit): подтверждение, сохранение ввода и повторные нажатия. Реальные устройства и payload восстановления rules/routes остаются вне проверки этого блока.
|
||||
|
||||
## Блок 9.14 — инициализация IPsec (2026-09-29)
|
||||
|
||||
Проверяются схема сводки, serverId и initialized=true. Ошибка остаётся в форме с введёнными значениями; замок блокирует повтор, редактирование и закрытие во время запроса. Исправлен ввод IPv4, добавлены доступные имена. Новый E2E и 3 теста таблиц прошли, lint/TypeScript чистые. Далее — создание/редактирование пользователей IPsec. Реальные устройства и транзакционность сетевой операции не проверены; последний build — 9.13.
|
||||
+7
-1
@@ -1,3 +1,4 @@
|
||||
import { ipsecServerSummaryDtoSchema } from "@mmapp/contracts/ipsec"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import type {
|
||||
IpsecCertBundle,
|
||||
@@ -24,10 +25,15 @@ export async function initIpsecServer(
|
||||
baseUrl: string,
|
||||
body: IpsecInitRequest,
|
||||
): Promise<IpsecServerSummaryDto> {
|
||||
return requestJson<IpsecServerSummaryDto>(baseUrl, "/api/ipsec/server/init", {
|
||||
const response = await requestJson<unknown>(baseUrl, "/api/ipsec/server/init", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const result = ipsecServerSummaryDtoSchema.safeParse(response)
|
||||
if (!result.success || result.data.serverId !== body.serverId || !result.data.initialized) {
|
||||
throw new Error("Сервер не подтвердил инициализацию IKEv2")
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function deleteIpsecServer(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
|
||||
test("IPsec: инициализация сохраняет ввод, блокирует повтор и требует подтверждения", async ({ page }) => {
|
||||
const summary = { serverId: "1", serverName: "IPsec роутер", initialized: false, ike2Ready: false, natRuleManaged: false, clientsTotal: 0, clientsOnline: 0, peers: [] }
|
||||
let attempts = 0
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
if (path === "/api/ipsec/server/init") {
|
||||
attempts++
|
||||
expect(route.request().postDataJSON()).toMatchObject({ serverId: "1", serverEndpoint: "192.0.2.1", poolCidr: "10.78.0.0/24", dns: "1.1.1.1", createNatRule: false })
|
||||
if (attempts === 1) {
|
||||
await gate
|
||||
return route.fulfill({ status: 403, json: { error: "Инициализация запрещена тест" } })
|
||||
}
|
||||
if (attempts === 2) return route.fulfill({ json: {} })
|
||||
if (attempts === 3) return route.fulfill({ json: summary })
|
||||
if (attempts === 4) return route.fulfill({ json: { ...summary, initialized: true, serverId: "2" } })
|
||||
return route.fulfill({ status: 201, json: { ...summary, initialized: true, ike2Ready: true } })
|
||||
}
|
||||
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
|
||||
: path === "/api/ipsec" ? { servers: [summary], clients: [] }
|
||||
: path === "/api/servers" ? [{ id: 1, name: "IPsec роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} })
|
||||
})
|
||||
await page.goto("/ipsec")
|
||||
await page.getByRole("tab", { name: "Сервер", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Инициализировать", exact: true }).click()
|
||||
const sheet = page.getByRole("dialog", { name: "Инициализация IKEv2-сервера" })
|
||||
const submit = sheet.getByRole("button", { name: "Инициализировать", exact: true })
|
||||
await expect(sheet.getByLabel("Адрес сервера (CN/SAN)")).toHaveValue("192.0.2.1")
|
||||
await expect(submit).toBeEnabled()
|
||||
await sheet.getByLabel("Адрес сервера (CN/SAN)").fill("999.0.0.1")
|
||||
await expect(submit).toBeDisabled()
|
||||
await sheet.getByLabel("Адрес сервера (CN/SAN)").fill("vpn.example.com")
|
||||
await expect(submit).toBeEnabled()
|
||||
await sheet.getByLabel("Адрес сервера (CN/SAN)").fill("192.0.2.1")
|
||||
await sheet.getByLabel("Подсеть клиентов").fill("10.78.0.0/24")
|
||||
await sheet.getByLabel("DNS для клиентов").fill("1.1.1.1")
|
||||
await sheet.getByRole("switch", { name: "Интернет клиентам (NAT)" }).click()
|
||||
await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
|
||||
await expect.poll(() => attempts).toBe(1)
|
||||
await expect(sheet.getByRole("status")).toContainText("Ожидание результата")
|
||||
await expect(sheet.getByLabel("Подсеть клиентов")).toBeDisabled()
|
||||
await expect(sheet.getByLabel("Сервер", { exact: true })).toBeDisabled()
|
||||
await expect(sheet.getByRole("switch")).toBeDisabled()
|
||||
await expect(sheet.getByRole("button", { name: "Отмена" })).toBeDisabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.mouse.click(10, 10)
|
||||
await expect(sheet).toBeVisible()
|
||||
release!()
|
||||
await expect(sheet.getByRole("alert").filter({ hasText: "Инициализация запрещена тест" })).toBeVisible()
|
||||
await expect(sheet.getByLabel("Подсеть клиентов")).toHaveValue("10.78.0.0/24")
|
||||
await expect(sheet.getByLabel("DNS для клиентов")).toHaveValue("1.1.1.1")
|
||||
for (let attempt = 2; attempt <= 4; attempt++) {
|
||||
await submit.click()
|
||||
await expect.poll(() => attempts).toBe(attempt)
|
||||
await expect(sheet.getByRole("alert").filter({ hasText: "Сервер не подтвердил инициализацию IKEv2" })).toBeVisible()
|
||||
await expect(page.getByText("IKEv2-сервер инициализирован", { exact: true })).toHaveCount(0)
|
||||
}
|
||||
await submit.click()
|
||||
await expect(sheet).toHaveCount(0)
|
||||
expect(attempts).toBe(5)
|
||||
await page.getByRole("button", { name: "Инициализировать", exact: true }).click()
|
||||
await expect(sheet.getByLabel("Подсеть клиентов")).toHaveValue("10.77.0.0/24")
|
||||
await expect(sheet.getByText("Сервер не подтвердил инициализацию IKEv2", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
Reference in new issue
Block a user