From 4a8d696e4e767b4452c5a067658b0ee75de40282 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 29 Sep 2026 19:04:00 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D1=80=D0=B5=D0=B2=D0=B8=D0=B7=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/filters/page.tsx | 27 +++++--- app/(main)/firewall/page.tsx | 25 ++++--- app/(main)/gre/page.tsx | 27 +++++--- app/(main)/ipsec/page.tsx | 7 +- app/(main)/recursive-routes/page.tsx | 27 +++++--- docs/ux-ui-plan.md | 4 +- docs/ux-ui-progress.md | 10 ++- docs/ux-ui-stage-9.md | 6 +- lib/config-revisions.ts | 32 ++++++++- shared/api/wireguard.ts | 16 +---- tests/e2e/config-history.spec.ts | 100 ++++++++++++++++----------- 11 files changed, 183 insertions(+), 98 deletions(-) diff --git a/app/(main)/filters/page.tsx b/app/(main)/filters/page.tsx index 1a7c3b71..9198face 100644 --- a/app/(main)/filters/page.tsx +++ b/app/(main)/filters/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useMemo, useState, useCallback, useEffect } from "react" +import { useMemo, useState, useCallback, useEffect, useRef } from "react" import { PageHeader } from "@/components/page-header" import { EmptyState } from "@/components/empty-state" import { StatusDot } from "@/components/status-dot" @@ -37,7 +37,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { toast } from "sonner" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { ConfigHistorySheet } from "@/components/config-history-sheet" -import type { ConfigRevisionDto } from "@/lib/config-revisions" +import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { type ServerTileItem } from "@/components/server-tile-rail" @@ -1334,6 +1334,8 @@ export default function FiltersPage() { const [liveStale, setLiveStale] = useState(false) const [historyOpen, setHistoryOpen] = useState(false) const [historyLoading, setHistoryLoading] = useState(false) + const [historyError, setHistoryError] = useState(null) + const historyEpoch = useRef(0) const [historyRestoring, setHistoryRestoring] = useState(false) const [revisions, setRevisions] = useState([]) @@ -1508,19 +1510,24 @@ export default function FiltersPage() { } }, [isLive, selectedServerId, applyBusy, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes]) + useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive]) + const loadRevisions = useCallback(async () => { if (!isLive || !selectedServerId) return + const epoch = ++historyEpoch.current setHistoryLoading(true) + setHistoryError(null) try { - const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>( + const res = await apiFetch( `/api/filters/revisions?serverId=${encodeURIComponent(selectedServerId)}`, ) - setRevisions(res.revisions) - } catch (err) { - toast.error("Не удалось загрузить историю", { description: String(err) }) - setRevisions([]) + if (epoch !== historyEpoch.current) return + setRevisions(parseConfigRevisions(res, selectedServerId, "filters")) + } catch (error) { + if (epoch !== historyEpoch.current) return + setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю") } finally { - setHistoryLoading(false) + if (epoch === historyEpoch.current) setHistoryLoading(false) } }, [isLive, selectedServerId, apiFetch]) @@ -1881,7 +1888,9 @@ export default function FiltersPage() { { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }} + loadError={historyError} + onRetry={() => { void loadRevisions() }} title="История фильтров" itemLabel="правил" revisions={revisions} diff --git a/app/(main)/firewall/page.tsx b/app/(main)/firewall/page.tsx index c6c9ef7d..d8398a4c 100644 --- a/app/(main)/firewall/page.tsx +++ b/app/(main)/firewall/page.tsx @@ -56,7 +56,7 @@ import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-ra import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" import { toast } from "sonner" import { ConfigHistorySheet } from "@/components/config-history-sheet" -import type { ConfigRevisionDto } from "@/lib/config-revisions" +import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions" // ─── Types ──────────────────────────────────────────────────────────────────── @@ -1952,6 +1952,8 @@ function FirewallPageInner() { const [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) const [historyLoading, setHistoryLoading] = useState(false) + const [historyError, setHistoryError] = useState(null) + const historyEpoch = useRef(0) const [historyRestoring, setHistoryRestoring] = useState(false) const loadLive = useCallback(async () => { @@ -1978,19 +1980,24 @@ function FirewallPageInner() { const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId + useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive]) + const loadRevisions = useCallback(async () => { if (!isLive || !historyServerId) return + const epoch = ++historyEpoch.current setHistoryLoading(true) + setHistoryError(null) try { - const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>( + const res = await apiFetch( `/api/firewall/revisions?serverId=${encodeURIComponent(historyServerId)}`, ) - setRevisions(res.revisions) - } catch (err) { - toast.error("Не удалось загрузить историю", { description: String(err) }) - setRevisions([]) + if (epoch !== historyEpoch.current) return + setRevisions(parseConfigRevisions(res, historyServerId, "firewall")) + } catch (error) { + if (epoch !== historyEpoch.current) return + setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю") } finally { - setHistoryLoading(false) + if (epoch === historyEpoch.current) setHistoryLoading(false) } }, [isLive, historyServerId, apiFetch]) @@ -2645,7 +2652,9 @@ function FirewallPageInner() { { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }} + loadError={historyError} + onRetry={() => { void loadRevisions() }} title="История Firewall" itemLabel="объектов" revisions={revisions} diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx index 405c3da6..e07ae48f 100644 --- a/app/(main)/gre/page.tsx +++ b/app/(main)/gre/page.tsx @@ -1,6 +1,6 @@ "use client" -import { Suspense, useCallback, useEffect, useMemo, useState } from "react" +import { Suspense, useCallback, useEffect, useRef, useMemo, useState } from "react" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" @@ -21,7 +21,7 @@ import { OpsPanel } from "@/components/ops-panel" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { ConfigHistorySheet } from "@/components/config-history-sheet" -import type { ConfigRevisionDto } from "@/lib/config-revisions" +import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions" import { AlertDialog, AlertDialogAction, @@ -280,6 +280,8 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on const [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) const [historyLoading, setHistoryLoading] = useState(false) + const [historyError, setHistoryError] = useState(null) + const historyEpoch = useRef(0) const [historyRestoring, setHistoryRestoring] = useState(false) const [poolOpen, setPoolOpen] = useState(false) const [codePreviewTunnel, setCodePreviewTunnel] = useState(null) @@ -373,19 +375,24 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId const mutationsLocked = isLive && (mutateBusy || liveStale || selectedTunnelError || historyRestoring) + useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive]) + const loadRevisions = useCallback(async () => { if (!isLive || !historyServerId) return + const epoch = ++historyEpoch.current setHistoryLoading(true) + setHistoryError(null) try { - const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>( + const res = await apiFetch( `/api/gre/revisions?serverId=${encodeURIComponent(historyServerId)}`, ) - setRevisions(res.revisions) - } catch (err) { - toast.error("Не удалось загрузить историю", { description: String(err) }) - setRevisions([]) + if (epoch !== historyEpoch.current) return + setRevisions(parseConfigRevisions(res, historyServerId, "gre")) + } catch (error) { + if (epoch !== historyEpoch.current) return + setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю") } finally { - setHistoryLoading(false) + if (epoch === historyEpoch.current) setHistoryLoading(false) } }, [isLive, historyServerId, apiFetch]) @@ -1073,7 +1080,9 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }} + loadError={historyError} + onRetry={() => { void loadRevisions() }} title="История GRE" itemLabel="туннелей" revisions={revisions} diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 4b30dda2..1a0ad4f9 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -61,7 +61,7 @@ import { type ServerTileItem, } from "@/components/server-tile-rail" import { ConfigHistorySheet } from "@/components/config-history-sheet" -import type { ConfigRevisionDto } from "@/lib/config-revisions" +import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions" import { toast } from "sonner" import { findFreePoolIp } from "@/lib/ipsec-client" import { @@ -255,13 +255,12 @@ export default function IpsecPage() { setHistoryLoading(true) setHistoryError(null) try { - const res = await requestJson<{ revisions: ConfigRevisionDto[] }>( + const res = await requestJson( backendUrl, `/api/ipsec/revisions?serverId=${encodeURIComponent(historyServerId)}`, ) if (epoch !== historyEpoch.current) return - if (!Array.isArray(res?.revisions)) throw new Error("Некорректный ответ истории IPsec") - setRevisions(res.revisions) + setRevisions(parseConfigRevisions(res, historyServerId, "ipsec")) } catch (err) { if (epoch !== historyEpoch.current) return setHistoryError(err instanceof Error ? err.message : "Не удалось загрузить историю") diff --git a/app/(main)/recursive-routes/page.tsx b/app/(main)/recursive-routes/page.tsx index 3ee2aa32..34d8e100 100644 --- a/app/(main)/recursive-routes/page.tsx +++ b/app/(main)/recursive-routes/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useRef, useMemo, useState } from "react" import { PageHeader } from "@/components/page-header" import { RecursiveRoutesDataGrid, @@ -23,7 +23,7 @@ import { toast } from "sonner" import { requestJson } from "@/shared/api/http-client" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ConfigHistorySheet } from "@/components/config-history-sheet" -import type { ConfigRevisionDto } from "@/lib/config-revisions" +import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions" import { type ServerTileItem } from "@/components/server-tile-rail" interface BackendServer { @@ -379,6 +379,8 @@ export default function RecursiveRoutesPage() { const [liveStale, setLiveStale] = useState(false) const [historyOpen, setHistoryOpen] = useState(false) const [historyLoading, setHistoryLoading] = useState(false) + const [historyError, setHistoryError] = useState(null) + const historyEpoch = useRef(0) const [historyRestoring, setHistoryRestoring] = useState(false) const [revisions, setRevisions] = useState([]) /** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */ @@ -472,19 +474,24 @@ export default function RecursiveRoutesPage() { } }, [isLive, selectedServerId, liveStale, rows, apiFetch]) + useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive]) + const loadRevisions = useCallback(async () => { if (!isLive || !selectedServerId) return + const epoch = ++historyEpoch.current setHistoryLoading(true) + setHistoryError(null) try { - const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>( + const res = await apiFetch( `/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`, ) - setRevisions(res.revisions) - } catch (e) { - toast.error(e instanceof Error ? e.message : "Не удалось загрузить историю") - setRevisions([]) + if (epoch !== historyEpoch.current) return + setRevisions(parseConfigRevisions(res, selectedServerId, "recursive-routes")) + } catch (error) { + if (epoch !== historyEpoch.current) return + setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю") } finally { - setHistoryLoading(false) + if (epoch === historyEpoch.current) setHistoryLoading(false) } }, [isLive, selectedServerId, apiFetch]) @@ -752,7 +759,9 @@ export default function RecursiveRoutesPage() { { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }} + loadError={historyError} + onRetry={() => { void loadRevisions() }} title="История маршрутов" itemLabel="маршрутов" revisions={revisions} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 14776065..d263e11f 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -80,7 +80,7 @@ flowchart LR ## Текущая точка продолжения -По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.12 — история WireGuard/IPsec и восстановление); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. +По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.13 — история всех шести разделов и восстановление); этапы 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. Далее — история GRE/Firewall/Filters/рекурсивных маршрутов и общая валидация ревизий; затем IPsec/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 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. Далее — инициализация IPsec: подтверждение API, сохранение формы и защита повторной отправки; затем пользователи/сертификаты и GRE/VXLAN/Firewall/Filters. 2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения. 3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index fd838359..7ce31e6f 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -25,12 +25,20 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.12 — история WireGuard/IPsec и восстановление | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.13 — история всех шести разделов и восстановление | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.13 — общая проверка и загрузка истории (2026-09-29) + +- GRE, Firewall, Filters и рекурсивные маршруты подключены к ошибке/повтору общей панели. Ошибка не обнуляет сохранённые ревизии и не маскируется пустым списком. Поколение запроса защищает результат, ошибку и finally после закрытия, смены контекста и размонтирования; физическая отмена запросов не добавлялась. +- Общая parseConfigRevisions проверяет контейнер, поля записей, дату, количество, источник, раздел и принадлежность выбранному серверу во всех шести потребителях. Тип ConfigRevisionDto дополнен IPsec согласно backend; WireGuard использует ту же схему. Backend и правила применения не изменены. +- Все 36 E2E истории/WireGuard прошли. Пять сценариев истории проверяют 403, задержанный ответ закрытой панели, отсутствие контейнера, другой сервер/раздел, повреждённую дату и успешный пустой список. TypeScript прошёл. Общая схема, API и тест lint-чистые; 7 исходных ошибок и 31 предупреждение страниц не изменились (сравнение с HEAD). Production build прошёл: 34/34 страницы. +- Следующий шаг: IPsec — handleInit и форма инициализации: подтверждение API, сохранение ввода при ошибке, блокировка повторной отправки и закрытия до ответа; затем пользователи/сертификаты и остальные разделы карты. Полная проверка payload восстановления rules/routes, реальные RouterOS/JWT и весь этап 9 пока не завершены. +- После профильных проверок: использовано 21% пятичасовой и 81% недельной квоты. Локальный коммит без push; чужие изменения skill/config/AGENTS сохранены. + ## Блок 9.12 — ошибка и повтор истории IPsec (2026-09-29) - IPsec подключён к loadError/onRetry общего диалога. Ошибка не обнуляет историю и не показывает пустое состояние; прежние ревизии скрыты до успешного повтора. Ответ без массива revisions отклоняется. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 0bac17f5..90f24465 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -1,6 +1,6 @@ # Этап 9: VPN и сетевые операции -Статус: начат 2026-09-28, выполнены блоки 9.1–9.12. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. +Статус: начат 2026-09-28, выполнены блоки 9.1–9.13. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. ## Схема и референсы принятого плана @@ -89,3 +89,7 @@ IPsec/GRE/Firewall/Filters/рекурсивные маршруты требую ## Блок 9.12 — загрузка истории IPsec (2026-09-29) Ошибка и повтор подключены к общей панели; поздние ответы закрытого/сменившего контекст запроса игнорируются. Проверяется наличие массива revisions, без полной валидации записей. Все 7 E2E истории прошли. Далее — GRE/Firewall/Filters/рекурсивные маршруты и общая схема ревизий. Реальные устройства не проверены. + +## Блок 9.13 — история всех разделов (2026-09-29) + +Ошибка/повтор и защита поздних ответов подключены к GRE/Firewall/Filters/рекурсивным маршрутам. Общая схема проверяет записи, сервер и раздел во всех шести потребителях, включая IPsec. Все 36 E2E истории/WireGuard и TypeScript прошли; исходный lint страниц не изменился, новые общие файлы и тест чистые. Результат build — в журнале. Далее — форма инициализации IPsec (handleInit): подтверждение, сохранение ввода и повторные нажатия. Реальные устройства и payload восстановления rules/routes остаются вне проверки этого блока. diff --git a/lib/config-revisions.ts b/lib/config-revisions.ts index 181d0774..3c97f9d4 100644 --- a/lib/config-revisions.ts +++ b/lib/config-revisions.ts @@ -1,12 +1,42 @@ +import { z } from "zod" + export type ConfigRevisionSource = "apply" | "rollback" | "observed" | "copy" export interface ConfigRevisionDto { id: string serverId: string - section: "filters" | "recursive-routes" | "firewall" | "wireguard" | "gre" + section: "filters" | "recursive-routes" | "firewall" | "wireguard" | "gre" | "ipsec" source: ConfigRevisionSource fingerprint: string createdAt: string note: string | null itemCount: number } + +const revisionsSchema = z.object({ revisions: z.array(z.object({ + id: z.string().min(1), + serverId: z.string().min(1), + section: z.enum(["filters", "recursive-routes", "firewall", "wireguard", "gre", "ipsec"]), + source: z.enum(["apply", "rollback", "observed", "copy"]), + fingerprint: z.string(), + createdAt: z.string().refine(value => Number.isFinite(Date.parse(value))), + note: z.string().nullable(), + itemCount: z.number().int().nonnegative(), +})) }) + +const sectionLabels: Record = { + filters: "Filters", "recursive-routes": "рекурсивных маршрутов", firewall: "Firewall", + wireguard: "WireGuard", gre: "GRE", ipsec: "IPsec", +} + +export function parseConfigRevisions( + payload: unknown, + serverId: string, + section: ConfigRevisionDto["section"], +): ConfigRevisionDto[] { + const result = revisionsSchema.safeParse(payload) + if (!result.success || result.data.revisions.some(revision => revision.serverId !== serverId || revision.section !== section)) { + throw new Error("Некорректный ответ истории " + sectionLabels[section]) + } + return result.data.revisions +} diff --git a/shared/api/wireguard.ts b/shared/api/wireguard.ts index 860139a4..0f93ad40 100644 --- a/shared/api/wireguard.ts +++ b/shared/api/wireguard.ts @@ -1,4 +1,4 @@ -import { z } from "zod" +import { parseConfigRevisions } from "@/lib/config-revisions" import { wgIfaceDtoSchema, wgImportResponseSchema, wgExportResponseSchema } from "@mmapp/contracts/wireguard" import type { WgCreateInterface, @@ -128,17 +128,7 @@ export async function exportWireGuard( return parsed.data } -const wireguardRevisionsSchema = z.object({ revisions: z.array(z.object({ - id: z.string().min(1), serverId: z.string().min(1), section: z.literal("wireguard"), - source: z.enum(["apply", "rollback", "observed", "copy"]), fingerprint: z.string(), - createdAt: z.string().refine(value => Number.isFinite(Date.parse(value))), - note: z.string().nullable(), itemCount: z.number().int().nonnegative(), -})) }) - export async function listWireGuardRevisions(baseUrl: string, serverId: string) { - const result = wireguardRevisionsSchema.safeParse(await requestJson(baseUrl, "/api/wireguard/revisions?serverId=" + encodeURIComponent(serverId))) - if (!result.success || result.data.revisions.some(revision => revision.serverId !== serverId)) { - throw new Error("Некорректный ответ истории WireGuard") - } - return result.data.revisions + const result = await requestJson(baseUrl, "/api/wireguard/revisions?serverId=" + encodeURIComponent(serverId)) + return parseConfigRevisions(result, serverId, "wireguard") } diff --git a/tests/e2e/config-history.spec.ts b/tests/e2e/config-history.spec.ts index ec275416..2d6b42a7 100644 --- a/tests/e2e/config-history.spec.ts +++ b/tests/e2e/config-history.spec.ts @@ -68,47 +68,65 @@ for (const section of ["wireguard", "ipsec", "gre", "firewall", "filters", "recu }) } -test("История IPsec: ошибка, повтор и поздний ответ закрытой панели", async ({ page }) => { - let attempts = 0 - let release: (() => void) | undefined - const gate = new Promise(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/revisions") { - attempts++ - if (attempts === 1) return route.fulfill({ status: 403, json: { error: "История недоступна тест" } }) - if (attempts === 2) { - await gate - return route.fulfill({ status: 500, json: { error: "Устаревшая ошибка тест" } }) +for (const section of ["ipsec", "gre", "firewall", "filters", "recursive-routes"]) { + test(`История ${section}: ошибка, повтор и поздний ответ закрытой панели`, async ({ page }) => { + let attempts = 0 + let release: (() => void) | undefined + const gate = new Promise(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/${section}/revisions`) { + attempts++ + if (attempts === 1) return route.fulfill({ status: 403, json: { error: "История недоступна тест" } }) + if (attempts === 2) { + await gate + return route.fulfill({ status: 500, json: { error: "Устаревшая ошибка тест" } }) + } + if (attempts === 3) return route.fulfill({ json: {} }) + const revision = { id: "rev/1", serverId: "1", section, source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 } + if (attempts === 4) return route.fulfill({ json: { revisions: [{ ...revision, serverId: "2" }] } }) + if (attempts === 5) return route.fulfill({ json: { revisions: [{ ...revision, section: "wireguard" }] } }) + if (attempts === 6) return route.fulfill({ json: { revisions: [{ ...revision, createdAt: "invalid" }] } }) + return route.fulfill({ json: { revisions: [] } }) } - if (attempts === 3) return route.fulfill({ json: {} }) - return route.fulfill({ json: { revisions: [] } }) + return route.fulfill({ json: path === "/api/auth/config" ? { required: false } + : path === "/api/ipsec" ? { servers: [], clients: [] } + : path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] } + : path === "/api/firewall/all" ? { rules: [], addressLists: [] } + : path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true } + : path === "/api/recursive-routes" ? { routes: [] } + : path === "/api/recursive-routes/gateways" ? { gateways: [] } + : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } + : path === "/api/servers" ? [{ id: 1, name: "История роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} }) + }) + await page.goto(`/${section}?server=1`) + if (section === "ipsec") await page.getByRole("option").filter({ hasText: "История роутер" }).click() + const open = page.getByRole("button", { name: "История", exact: true }) + const sheet = page.getByRole("dialog") + await open.click() + await expect(sheet.getByRole("alert")).toContainText("История недоступна тест") + await expect(sheet.getByText("Истории пока нет", { exact: true })).toHaveCount(0) + await sheet.getByRole("button", { name: "Повторить загрузку" }).click() + await expect.poll(() => attempts).toBe(2) + await expect(sheet.getByRole("status")).toContainText("Загрузка истории") + await page.keyboard.press("Escape") + await expect(sheet).toHaveCount(0) + await open.click() + await expect(sheet.getByRole("alert")).toContainText("Некорректный ответ истории") + for (let attempt = 4; attempt <= 6; attempt++) { + await sheet.getByRole("button", { name: "Повторить загрузку" }).click() + await expect.poll(() => attempts).toBe(attempt) + await expect(sheet.getByRole("alert")).toContainText("Некорректный ответ истории") + await expect(sheet.getByRole("button", { name: "Откатить", exact: true })).toHaveCount(0) } - return route.fulfill({ json: path === "/api/auth/config" ? { required: false } - : path === "/api/ipsec" ? { servers: [], clients: [] } - : path === "/api/servers" ? [{ id: 1, name: "История роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} }) + await sheet.getByRole("button", { name: "Повторить загрузку" }).click() + await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible() + const response = page.waitForResponse(r => r.url().includes(`/api/${section}/revisions?`)) + release!() + await (await response).finished() + await page.waitForTimeout(150) + await expect(sheet.getByRole("alert")).toHaveCount(0) + await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible() }) - await page.goto("/ipsec") - await page.getByRole("option").filter({ hasText: "История роутер" }).click() - const open = page.getByRole("button", { name: "История", exact: true }) - const sheet = page.getByRole("dialog", { name: "История конфигураций IPsec" }) - await open.click() - await expect(sheet.getByRole("alert")).toContainText("История недоступна тест") - await expect(sheet.getByText("Истории пока нет", { exact: true })).toHaveCount(0) - await sheet.getByRole("button", { name: "Повторить загрузку" }).click() - await expect.poll(() => attempts).toBe(2) - await expect(sheet.getByRole("status")).toContainText("Загрузка истории") - await page.keyboard.press("Escape") - await expect(sheet).toHaveCount(0) - await open.click() - await expect(sheet.getByRole("alert")).toContainText("Некорректный ответ истории IPsec") - await sheet.getByRole("button", { name: "Повторить загрузку" }).click() - await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible() - const response = page.waitForResponse(r => r.url().includes("/api/ipsec/revisions?")) - release!() - await (await response).finished() - await page.waitForTimeout(150) - await expect(sheet.getByRole("alert")).toHaveCount(0) - await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible() -}) +}