diff --git a/app/(main)/filters/page.tsx b/app/(main)/filters/page.tsx index ccc66616..15239e5d 100644 --- a/app/(main)/filters/page.tsx +++ b/app/(main)/filters/page.tsx @@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { cn } from "@/lib/utils" -import { filtersResponseSchema, filtersMutationSchema } from "@/lib/filters-response" +import { filtersResponseSchema, filtersMutationSchema, filtersGreResponseSchema, filtersRecursiveResponseSchema } from "@/lib/filters-response" import { firewallServersSchema } from "@/lib/firewall-response" import { requestJson } from "@/shared/api/http-client" import { @@ -818,6 +818,7 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL // ── Copy Rules Sheet ────────────────────────────────────────────────────────── type CopyMode = "append" | "replace" +type GatewayReadiness = Record>> /** Try to find an equivalent tunnel on the target server (same remoteAddress). */ function mapTunnel( @@ -876,6 +877,7 @@ function CopyRulesSheet({ ensureGreFor, recRoutesByServer, ensureRecursiveFor, + catalogReady, }: { open: boolean; onClose: () => void sourceServerId: string; sourceRules: FilterRule[] @@ -886,6 +888,7 @@ function CopyRulesSheet({ ensureGreFor?: (serverId: string) => void recRoutesByServer: Record ensureRecursiveFor?: (serverId: string) => void + catalogReady?: GatewayReadiness }) { const [selected, setSelected] = useState>(() => new Set(sourceRules.map(r => r.id))) const [targetId, setTargetId] = useState(null) @@ -972,7 +975,7 @@ function CopyRulesSheet({ } async function handleCopy() { - if (copyLock.current || !targetId || selectedRules.length === 0) return + if (copyLock.current || !targetId || selectedRules.length === 0 || !mappingReady) return copyLock.current = true setCopying(true) setCopyError(null) @@ -1010,7 +1013,18 @@ function CopyRulesSheet({ } const close = () => { if (!copyLock.current) onClose() } - const canCopy = targetId !== null && selectedRules.length > 0 && !copying + const requiredKinds = selectedRules.filter(r => (r.action ?? "route") === "route").map(r => isRecursiveGatewayRef(r.gatewayTunnelId) ? "recursive" as const : "gre" as const) + const catalogsReady = !catalogReady || (targetId !== null && requiredKinds.every(kind => catalogReady[sourceServerId]?.[kind] && catalogReady[targetId]?.[kind])) + const mappingReady = catalogsReady && selectedRules.every(rule => { + if ((rule.action ?? "route") === "blackhole") return true + if (!targetId) return false + if (isRecursiveGatewayRef(rule.gatewayTunnelId)) { + const source = sourceRecursiveRoutes.find(r => r.id === rule.gatewayTunnelId.slice(4)) + return !!source && !!pickRecursiveRouteForCopy(source, targetRecursiveRoutes) + } + return !!mapTunnel(rule.gatewayTunnelId, targetId, tunnelsList) + }) + const canCopy = targetId !== null && selectedRules.length > 0 && !copying && mappingReady return ( { if (!v) close() }}> @@ -1218,7 +1232,7 @@ function CopyRulesSheet({ ))} {mapping.some(m => !m.mapped) && (

- Правила с пустым шлюзом нужно отредактировать после копирования + Копирование недоступно, пока для каждого маршрута не найден шлюз целевого устройства

)} @@ -1226,6 +1240,10 @@ function CopyRulesSheet({ )} + {targetId && !mappingReady &&
+

Каталоги или сопоставление шлюзов не подтверждены. Копирование маршрутов заблокировано.

+ +
} {copyError &&

{copyError}

} {copying &&

Ожидание подтверждения копирования…

} @@ -1313,33 +1331,38 @@ function FiltersPage() { const [liveLoadError, setLiveLoadError] = useState(null) const [loadAttempt, setLoadAttempt] = useState(0) - const ensureGreTunnels = useCallback(async (serverId: string) => { - if (!isLive || !mounted.current) return - try { - const d = await apiFetch<{ tunnels: GreTunnel[] }>( - `/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`, - ) - if (!mounted.current) return - setGreByServer(prev => ({ ...prev, [serverId]: d.tunnels })) - } catch { - if (!mounted.current) return - setGreByServer(prev => ({ ...prev, [serverId]: [] })) - } - }, [isLive, apiFetch]) + const [gatewayReady, setGatewayReady] = useState({}) + const [gatewayErrors, setGatewayErrors] = useState>>>({}) + const gatewayEpoch = useRef>({}) - const ensureRecursiveRoutes = useCallback(async (serverId: string) => { + const loadGatewayCatalog = useCallback(async (serverId: string, kind: "gre" | "recursive") => { if (!isLive || !mounted.current) return + const key = serverId + ":" + kind + const epoch = (gatewayEpoch.current[key] ?? 0) + 1 + gatewayEpoch.current[key] = epoch + setGatewayReady(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: false } })) try { - const d = await apiFetch<{ routes: RecursiveRouteLite[] }>( - `/api/recursive-routes?serverId=${encodeURIComponent(serverId)}`, - ) - if (!mounted.current) return - setRecRoutesByServer(prev => ({ ...prev, [serverId]: d.routes })) - } catch { - if (!mounted.current) return - setRecRoutesByServer(prev => ({ ...prev, [serverId]: [] })) + const path = kind === "gre" ? "/api/filters/gre-tunnels" : "/api/recursive-routes" + const payload = await apiFetch(`${path}?serverId=${encodeURIComponent(serverId)}`) + if (!mounted.current || gatewayEpoch.current[key] !== epoch) return + if (kind === "gre") { + const parsed = filtersGreResponseSchema.safeParse(payload) + if (!parsed.success || parsed.data.tunnels.some(t => t.serverId !== serverId)) throw new Error("Некорректный каталог GRE") + setGreByServer(prev => ({ ...prev, [serverId]: parsed.data.tunnels })) + } else { + const parsed = filtersRecursiveResponseSchema.safeParse(payload) + if (!parsed.success || parsed.data.stale) throw new Error("Рекурсивные шлюзы недоступны или устарели") + setRecRoutesByServer(prev => ({ ...prev, [serverId]: parsed.data.routes })) + } + setGatewayReady(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: true } })) + setGatewayErrors(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: undefined } })) + } catch (error) { + if (!mounted.current || gatewayEpoch.current[key] !== epoch) return + setGatewayErrors(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: error instanceof Error ? error.message : "Каталог шлюзов недоступен" } })) } }, [isLive, apiFetch]) + const ensureGreTunnels = useCallback((serverId: string) => loadGatewayCatalog(serverId, "gre"), [loadGatewayCatalog]) + const ensureRecursiveRoutes = useCallback((serverId: string) => loadGatewayCatalog(serverId, "recursive"), [loadGatewayCatalog]) const [selectedServerId, setSelectedServerId] = useState("srv1") const [search, setSearch] = useState("") const [sheetOpen, setSheetOpen] = useState(false) @@ -1667,6 +1690,7 @@ function FiltersPage() { const handleSave = async (form: RuleForm): Promise => { if (mutationsLocked) return "Изменения заблокированы — обновите данные или дождитесь завершения операции" + if (isLive && form.action === "route" && !gatewayReady[selectedServerId]?.[form.gatewayKind]) return "Шлюзы устройства не подтверждены — обновите шлюзы" const { gatewayKind: _gk, ...payload } = form let result if (sheetMode === "create") { @@ -1848,6 +1872,10 @@ function FiltersPage() { )} + {isLive && Object.values(gatewayErrors[selectedServerId] ?? {}).some(Boolean) &&
+

{Object.values(gatewayErrors[selectedServerId] ?? {}).filter(Boolean).join(" · ")}. Сохранены последние каталоги шлюзов.

+ +
} {isLive && rulesReading &&

Чтение актуальных правил устройства… Изменения временно заблокированы.

} {isLive && liveStale && liveLoadState !== "error" && (
@@ -2058,6 +2086,7 @@ function FiltersPage() { ensureGreFor={ensureGreTunnels} recRoutesByServer={recRoutesByServer} ensureRecursiveFor={ensureRecursiveRoutes} + catalogReady={isLive ? gatewayReady : undefined} /> { try { const remote = await fetchServerFilters(server) return reply.send({ tunnels: remote.tunnels }) - } catch { - return reply.send({ tunnels: [] as LiveGreTunnel[] }) + } catch (error) { + app.log.warn({ serverId: sid, err: String(error) }, "filters GRE catalog unavailable") + return reply.status(503).send({ error: "Каталог GRE выбранного устройства недоступен" }) } } diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 84fcbfd6..e02a2b32 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.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 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. В 9.33 состояние изолировано по backend/режиму; поздние операции старого экрана не запускают чтение и не показывают уведомления в новом контексте. В 9.34 показано время получения срезов Firewall: успешные обновляются, недоступные сохраняют прежнее время или остаются неизвестными. В 9.35 сохранение Filters ожидает ok=true, сохраняет ввод при ошибке и блокирует повторную отправку/закрытие; live-список меняется после подтверждения. В 9.36 копирование Filters ждёт подтверждения, сохраняет цель/правила/режим при ошибке и блокирует повторы/закрытие. В 9.37 удаление Filters перенесено в диалог с сохранением ошибки, подтверждением API и защитой повторов. В 9.38 перемещение Filters подтверждается API; поиск использует полный порядок для приоритета и границ, действия таблицы блокируются на время операции. В 9.39 обновление/восстановление Filters используют общий замок до чтения правил/истории; ошибка чтения сохраняет подтверждение, чужой ruleset и stale отклоняются. В 9.40 добавлены схемы правил/ответов Filters, ошибка первоначальной загрузки с повтором без подмены моками и сохранение последних данных. В 9.41 запись выбранного устройства требует успешного чтения live=true, старые ответы отклоняются поколением запросов при переключении/применении. В 9.42 состояние Filters изолировано по backend/режиму, старые операции не уведомляют новый контекст. В 9.43 копирование Filters читает актуальный целевой набор под общим замком до PUT, ошибки чтения блокируют отправку. Далее — схемы/ошибки шлюзов и свежесть Filters; ограничения счётчика, свежести и записи VXLAN остаются открытыми; загрузка данных IPsec и общая приёмка остаются открытыми. +1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. В 9.33 состояние изолировано по backend/режиму; поздние операции старого экрана не запускают чтение и не показывают уведомления в новом контексте. В 9.34 показано время получения срезов Firewall: успешные обновляются, недоступные сохраняют прежнее время или остаются неизвестными. В 9.35 сохранение Filters ожидает ok=true, сохраняет ввод при ошибке и блокирует повторную отправку/закрытие; live-список меняется после подтверждения. В 9.36 копирование Filters ждёт подтверждения, сохраняет цель/правила/режим при ошибке и блокирует повторы/закрытие. В 9.37 удаление Filters перенесено в диалог с сохранением ошибки, подтверждением API и защитой повторов. В 9.38 перемещение Filters подтверждается API; поиск использует полный порядок для приоритета и границ, действия таблицы блокируются на время операции. В 9.39 обновление/восстановление Filters используют общий замок до чтения правил/истории; ошибка чтения сохраняет подтверждение, чужой ruleset и stale отклоняются. В 9.40 добавлены схемы правил/ответов Filters, ошибка первоначальной загрузки с повтором без подмены моками и сохранение последних данных. В 9.41 запись выбранного устройства требует успешного чтения live=true, старые ответы отклоняются поколением запросов при переключении/применении. В 9.42 состояние Filters изолировано по backend/режиму, старые операции не уведомляют новый контекст. В 9.43 копирование Filters читает актуальный целевой набор под общим замком до PUT, ошибки чтения блокируют отправку. В 9.44 схемы/ошибки каталогов Filters сохраняют последние шлюзы, блокируют недостоверный route/маппинг; выборный GRE GET сообщает 503 при отказе. Далее — свежесть и профильная приёмка Filters; ограничения счётчика, свежести и записи VXLAN остаются открытыми; загрузка данных IPsec и общая приёмка остаются открытыми. 2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения. 3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index a37b6e04..8093ce81 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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.43 — IPsec/GRE/VXLAN, Firewall и операции Filters | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.44 — IPsec/GRE/VXLAN, Firewall и операции Filters | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.44 — достоверные каталоги шлюзов Filters (2026-09-30) + +- GRE/recursive каталоги проверяются схемами и уникальностью ID; GRE должен принадлежать запрошенному serverId, recursive stale отклоняется. При HTTP/структурной ошибке сохраняется последний каталог, readiness остаётся false, виден alert с повтором. Поколения по устройству/типу каталога отсекают перекрывающиеся ответы; mounted-guard отменяет старый источник. +- Сохранение route требует подтверждённого каталога выбранного вида. Копирование route требует каталогов источника/цели и сопоставленного GRE/рекурсивного шлюза каждого правила; недостоверный либо отсутствующий маппинг блокирует кнопку, есть повтор. Blackhole не зависит от каталогов маршрутизации. Существующее сопоставление по remoteAddress либо dst/table/distance сохранено, сетевые правила не изменялись. +- Backend /filters/gre-tunnels?serverId=... возвращает 503 при отказе опроса вместо ложного успешного пустого списка. Общий GET без serverId пока сохраняет прежнюю частичную обработку и требует отдельного блока; реальные RouterOS/DB не проверены. Recursive backend уже сообщает stale=true, UI теперь его учитывает. GRE schema соответствует live API (ipsec=null), не предназначена для полного конфигурационного GRE-редактора. +- Прошли 12 E2E, два профильных сценария повторены после уточнения ожидания запросов. Проверены сохранение каталога при 503, отказ route-сохранения/копирования, повреждённый GRE либо stale recursive, пустой каталог без маппинга, повтор и успешное копирование. TypeScript frontend/backend и оба build прошли (frontend 34 страницы). Схема/новый тест lint-чистые; страница теперь 0 ошибок/13 прежних предупреждений (до блока 2/13), без отключения правил. Diff-check прошёл. Backend 503 проверен компиляцией/анализом контракта, без интеграционного теста с БД/RouterOS. +- Далее — свежесть полученных правил/каталогов Filters и подтверждения записи, затем профильная адаптивность/доступность и оставшиеся ограничения сетевых разделов. Этап 9 открыт. +- Квота: 81% пятичасовой/13% недельной перед блоком, 85%/13% после проверок. Пользователь разрешил продолжение до конца лимитов. Локальный коммит без push. + ## Блок 9.43 — актуальная цель копирования Filters (2026-09-30) - applyRules принимает подготовку правил под общим applyLock; копирование live читает целевой GET, требует корректный ruleset нужного устройства с live=true/без stale, затем объединяет актуальные правила с выбранными либо заменяет набор. Начальный кэш для append больше не используется. Валидное чтение обновляет сохранённый целевой набор; ошибка PUT не отменяет эти реальные прочитанные данные. В mock сохраняется прежняя локальная операция. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 931b6122..b598bb37 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -213,3 +213,7 @@ Backend сообщает failures по срезу, неполный snapshot о ## Блок 9.43 — цель копирования Filters (2026-09-30) Live-копирование читает актуальный целевой ruleset под общим замком до PUT. Ошибка/чужой/stale/повреждённый ответ сохраняют форму и не отправляют запись. Десять E2E и TypeScript прошли; новых lint замечаний нет. Далее — схемы/ошибки каталогов шлюзов, затем свежесть. Frontend build — 9.40, backend — 9.32. + +## Блок 9.44 — каталоги шлюзов Filters (2026-09-30) + +Схемы и readiness GRE/recursive сохраняют данные при отказе и блокируют route-сохранение/несопоставленное копирование до повтора. Выборный GRE backend GET возвращает 503 вместо пустого успеха. 12 E2E, повтор двух профильных, TypeScript frontend/backend и оба build прошли. Frontend lint: 0 ошибок/13 прежних предупреждений. Общий GRE GET/реальная БД/RouterOS не приняты. Далее — свежесть и профильная приёмка Filters. diff --git a/lib/filters-response.ts b/lib/filters-response.ts index 6d8e3ff2..7759c95b 100644 --- a/lib/filters-response.ts +++ b/lib/filters-response.ts @@ -11,3 +11,18 @@ export const filtersResponseSchema = z.object({ live: z.boolean().optional(), stale: z.boolean().optional(), error: z.string().optional(), }) export const filtersMutationSchema = z.object({ ok: z.literal(true), rules: filterRulesSchema.optional() }) + +export const filtersGreResponseSchema = z.object({ + tunnels: z.array(z.object({ + id: z.string().min(1), serverId: z.string().min(1), name: z.string(), localAddress: z.string(), remoteAddress: z.string(), + localInnerIp: z.string(), remoteInnerIp: z.string(), poolId: z.string(), ipsec: z.null(), mtu: z.number().int().nonnegative(), + keepaliveInterval: z.number().int().nonnegative(), keepaliveRetries: z.number().int().nonnegative(), + dscp: z.union([z.literal("inherit"), z.number().int().min(0).max(63)]), clampTcpMss: z.boolean(), allowFastPath: z.boolean(), + comment: z.string(), enabled: z.boolean(), status: z.enum(["up", "down", "degraded"]), + })).refine(rows => new Set(rows.map(r => r.id)).size === rows.length), +}) +export const filtersRecursiveResponseSchema = z.object({ + routes: z.array(z.object({ id: z.string().min(1), dstAddress: z.string(), gateway: z.string(), distance: z.number().int().nonnegative(), routingTable: z.string(), comment: z.string(), disabled: z.boolean() })) + .refine(rows => new Set(rows.map(r => r.id)).size === rows.length), + stale: z.boolean().optional(), +}) diff --git a/tests/e2e/filters-gateways.spec.ts b/tests/e2e/filters-gateways.spec.ts new file mode 100644 index 00000000..f30becf0 --- /dev/null +++ b/tests/e2e/filters-gateways.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test" +for (const kind of ["gre", "recursive"] as const) { + test(`Filters: каталог ${kind} сохраняется при сбое и блокирует маршрут/копирование`, async ({ page }) => { + let phase = 0, writes = 0, catalogReads = 0 + const tunnel = (id: string) => ({ id: "t" + id, serverId: id, name: "GRE-" + id, localAddress: "192.0.2." + id, remoteAddress: "198.51.100.1", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" }) + const rec = { id: "rec1", dstAddress: "10.0.0.2/32", gateway: "198.51.100.1", distance: 1, routingTable: "main", comment: "Рекурсивный шлюз", disabled: false } + const rule = { id: "rule-1", community: "65001:101", gateway: "10.0.0.2", gatewayTunnelId: kind === "gre" ? "t1" : "rec:rec1", action: "route", description: "Маршрут" } + await page.route("**/health", route => route.fulfill({ json: { ok: true } })) + await page.route("**/api/**", route => { + const url = new URL(route.request().url()), path = url.pathname, id = url.searchParams.get("serverId") ?? "1" + if (path === "/api/filters/rules" && route.request().method() === "PUT") { writes++; return route.fulfill({ json: { ok: true, rules: route.request().postDataJSON().rules } }) } + const catalog = kind === "gre" ? "/api/filters/gre-tunnels" : "/api/recursive-routes" + if (path === catalog) catalogReads++ + if (path === catalog && phase === 1) return route.fulfill({ status: 503, json: { error: "Каталог недоступен тест" } }) + if (path === catalog && phase === 2) return route.fulfill({ json: kind === "gre" ? { tunnels: [{ ...tunnel(id), name: null }] } : { routes: [rec], stale: true } }) + return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }, { serverId: "2", rules: [] }], live: true, greTunnels: [] } + : path === "/api/servers" ? [1, 2].map(i => ({ id: i, name: "Роутер " + i, host: "192.0.2." + i, type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 })) + : path === "/api/filters/gre-tunnels" ? { tunnels: kind === "gre" && phase !== 3 ? [tunnel(id)] : [] } + : path === "/api/recursive-routes" ? { routes: kind === "recursive" && phase !== 3 ? [rec] : [], stale: false } + : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/filters") + const row = page.locator("tr[data-row-id]").first() + await expect(row).toContainText(kind === "gre" ? "GRE-1" : rec.dstAddress) + phase = 1 + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Каталог недоступен тест" })).toBeVisible() + await expect(row).toContainText(kind === "gre" ? "GRE-1" : rec.dstAddress) + await row.hover() + await row.getByRole("button", { name: "Редактировать правило", exact: true }).click() + const dialog = page.getByRole("dialog") + await dialog.getByRole("textbox", { name: "Описание правила" }).fill("Сохранить маршрут") + await dialog.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(dialog.getByRole("alert")).toContainText("Шлюзы устройства не подтверждены") + expect(writes).toBe(0) + await dialog.getByRole("button", { name: "Отмена", exact: true }).click() + await page.getByRole("button", { name: "Копировать", exact: true }).click() + await dialog.getByRole("button", { name: /Роутер 2/ }).click() + const copy = dialog.getByRole("button", { name: "Копировать 1 правил", exact: true }) + await expect(copy).toBeDisabled() + phase = 2 + let priorReads = catalogReads + await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click() + await expect.poll(() => catalogReads).toBeGreaterThanOrEqual(priorReads + 2) + await expect(copy).toBeDisabled() + await expect(dialog.getByRole("alert")).toContainText("сопоставление шлюзов не подтверждены") + phase = 3 + priorReads = catalogReads + await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click() + await expect.poll(() => catalogReads).toBeGreaterThanOrEqual(priorReads + 2) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve()))) + await expect(copy).toBeDisabled() + phase = 4 + await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click() + await expect(copy).toBeEnabled() + await expect(dialog.getByRole("alert")).toHaveCount(0) + await copy.click() + await expect(dialog).toHaveCount(0) + expect(writes).toBe(1) + }) +}