fix(ui): проверять данные загрузки Filters

This commit is contained in:
Denozordec committed 2026-09-30 11:50:36 +07:00
1 parent 1fba16e1f2
commit 1c7ffa07a8
6 files changed
+143 -44

No files matched your search

+46 -42
View File
@@ -14,6 +14,8 @@ 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 { firewallServersSchema } from "@/lib/firewall-response"
import { requestJson } from "@/shared/api/http-client"
import {
servers, greTunnels, serverFilterRulesets, filters as mockFiltersCatalog,
@@ -1248,27 +1250,6 @@ function CopyRulesSheet({
// ── page ───────────────────────────────────────────────────────────────────────
interface BackendServer {
id: number
name: string
host: string
type: "jump-host" | "exit-node" | "home-router"
site: string
country: string
asn: string
enabled: boolean
status: "online" | "offline" | null
latency: number | null
}
interface LiveFiltersResponse {
rulesets: ServerFilterRuleset[]
greTunnels: GreTunnel[]
live?: boolean
stale?: boolean
error?: string
}
function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] {
return serverList.map(s => ({
serverId: s.id,
@@ -1318,12 +1299,15 @@ export default function FiltersPage() {
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
const [recRoutesByServer, setRecRoutesByServer] = useState<Record<string, RecursiveRouteLite[]>>({})
const [rulesets, setRulesets] = useState<ServerFilterRuleset[]>(
() => buildRulesets(servers, serverFilterRulesets),
() => isLive ? [] : buildRulesets(servers, serverFilterRulesets),
)
const [liveLoadState, setLiveLoadState] = useState<"idle" | "loading" | "error">(
() => (mode === "live" ? "loading" : "idle"),
)
const [liveLoadError, setLiveLoadError] = useState<string | null>(null)
const [loadAttempt, setLoadAttempt] = useState(0)
const ensureGreTunnels = useCallback(async (serverId: string) => {
if (!isLive) return
try {
@@ -1386,12 +1370,21 @@ export default function FiltersPage() {
return
}
let cancelled = false
setLiveLoadState("loading")
setLiveLoadError(null)
Promise.all([
apiFetch<BackendServer[]>("/api/servers"),
apiFetch<LiveFiltersResponse>("/api/filters/rules"),
apiFetch<unknown>("/api/servers"),
apiFetch<unknown>("/api/filters/rules"),
])
.then(([backendServers, liveFilters]) => {
.then(([serverPayload, filtersPayload]) => {
if (cancelled) return
const serverResult = firewallServersSchema.safeParse(serverPayload)
const filtersResult = filtersResponseSchema.safeParse(filtersPayload)
if (!serverResult.success || !filtersResult.success) throw new Error("Сервер вернул некорректные данные Filters")
const backendServers = serverResult.data, liveFilters = filtersResult.data
if (liveFilters.stale) throw new Error("Актуальные правила недоступны")
if (backendServers.some(server => server.enabled && !liveFilters.rulesets.some(rs => rs.serverId === String(server.id)))) throw new Error("Сервер не вернул наборы правил всех устройств")
const mappedServers: Server[] = backendServers.map(s => ({
id: String(s.id),
name: s.name || s.host,
@@ -1416,21 +1409,23 @@ export default function FiltersPage() {
))
setLiveLoadState("idle")
})
.catch(() => {
setLiveServers([])
setGreByServer({})
setRulesets(buildRulesets(servers, serverFilterRulesets))
setSelectedServerId(servers[0]?.id ?? "")
.catch(error => {
if (cancelled) return
setLiveLoadError(error instanceof Error ? error.message : "Не удалось загрузить Filters")
setLiveLoadState("error")
})
}, [isLive, apiFetch])
return () => { cancelled = true }
}, [isLive, apiFetch, loadAttempt])
const loadLiveRules = useCallback(async (serverId: string) => {
if (!isLive || !serverId) return false
try {
const fresh = await apiFetch<LiveFiltersResponse>(
const payload = await apiFetch<unknown>(
`/api/filters/rules?serverId=${encodeURIComponent(serverId)}`,
)
const parsed = filtersResponseSchema.safeParse(payload)
if (!parsed.success) throw new Error("Сервер вернул некорректные правила Filters")
const fresh = parsed.data
const ruleset = fresh.rulesets.find(r => r.serverId === serverId)
if (!ruleset || fresh.stale) throw new Error("Не удалось получить актуальные правила выбранного устройства")
const liveRules = ruleset.rules
@@ -1440,9 +1435,6 @@ export default function FiltersPage() {
return prev.map((rs) => rs.serverId === serverId ? { ...rs, rules: liveRules } : rs)
})
setLiveStale(Boolean(fresh.stale))
if (fresh.greTunnels?.length) {
setGreByServer((prev) => ({ ...prev, [serverId]: fresh.greTunnels }))
}
return true
} catch (err) {
setLiveStale(true)
@@ -1461,8 +1453,8 @@ export default function FiltersPage() {
])
}, [isLive, selectedServerId, liveLoadState, liveServers, ensureGreTunnels, ensureRecursiveRoutes, loadLiveRules])
const allServers = !isLive || liveLoadState === "error" ? servers : liveServers
const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? [])
const allServers = !isLive ? servers : liveServers
const allTunnels = !isLive ? greTunnels : (greByServer[selectedServerId] ?? [])
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
const mutationsLocked = movePending || (isLive && (applyBusy || historyRestoring || liveStale || liveLoadState !== "idle"))
@@ -1525,11 +1517,13 @@ export default function FiltersPage() {
applyLock.current = true
setApplyBusy(true)
try {
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>("/api/filters/rules", {
const payload = await apiFetch<unknown>("/api/filters/rules", {
method: "PUT",
body: JSON.stringify({ serverId, rules: nextRules, source }),
})
if (res?.ok !== true) throw new Error("Сервер не подтвердил применение правил")
const parsed = filtersMutationSchema.safeParse(payload)
if (!parsed.success) throw new Error("Сервер не подтвердил применение правил")
const res = parsed.data
setRulesets(p => p.map(rs => rs.serverId === serverId ? { ...rs, rules: res.rules ?? nextRules } : rs))
toast.success("Правила применены на роутер")
return { ok: true }
@@ -1587,11 +1581,13 @@ export default function FiltersPage() {
setApplyBusy(true)
setHistoryRestoring(true)
try {
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>(
const payload = await apiFetch<unknown>(
`/api/filters/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
)
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
const parsed = filtersMutationSchema.safeParse(payload)
if (!parsed.success) throw new Error("Сервер не подтвердил восстановление")
const res = parsed.data
if (res.rules) {
setRulesets((p) => p.map((rs) => rs.serverId === selectedServerId ? { ...rs, rules: res.rules ?? [] } : rs))
} else {
@@ -1711,6 +1707,13 @@ export default function FiltersPage() {
}
}
if (isLive && liveLoadState === "error" && liveServers.length === 0) {
return <div className="flex h-full flex-col items-center justify-center gap-3 p-6">
<p role="alert" className="text-sm text-destructive">Не удалось загрузить Filters: {liveLoadError}</p>
<Button variant="outline" onClick={() => setLoadAttempt(n => n + 1)}>Повторить загрузку</Button>
</div>
}
if (isLive && liveLoadState === "loading") {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
@@ -1792,7 +1795,8 @@ export default function FiltersPage() {
{isLive && liveLoadState === "error" && (
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
<AlertCircleIcon className="size-3.5 shrink-0" />
Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
<span role="alert">Не удалось загрузить Filters: {liveLoadError}. Сохранены последние данные.</span>
<Button variant="outline" size="sm" onClick={() => setLoadAttempt(n => n + 1)}>Повторить загрузку</Button>
</div>
)}
{isLive && liveStale && liveLoadState !== "error" && (
+1 -1
View File
@@ -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-спLine truncated
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-спLine truncated
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
+10 -1
View File
@@ -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.39 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.40 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 9.40 — схемы и первоначальная загрузка Filters (2026-09-30)
- lib/filters-response.ts проверяет обязательные поля правила, action, уникальность ID в наборе и serverId в контейнере. Схемы используются в первоначальном GET, чтении устройства, PUT применения и POST восстановления. Ответ с ok=true, но повреждёнными rules отклоняется без замены таблицы/закрытия формы. Для каталога устройств переиспользуется общий существующий контракт firewallServersSchema; не добавлены новые зависимости.
- В live исходные rulesets пусты; ошибка загрузки больше не переключает каталог/правила/GRE на встроенные моки. Есть явный alert и повтор первоначальной загрузки; успешная пустая конфигурация отличается от ошибки. Повторный сбой чтения устройства сохраняет последние строки и блокирует изменения. Проверяется наличие ruleset каждого включённого устройства: backend начальный GET исключает отключённые устройства. Первоначальный запрос имеет cleanup от позднего завершения; общей защиты поколениями остальных чтений ещё нет.
- GRE из GET правил больше не применяется без схемы: используются отдельные существующие ensureGreTunnels/ensureRecursiveRoutes. Их payload/ошибки пока не валидируются и могут маскироваться пустыми списками; это оставшийся объём. GET без serverId возвращает backend-кэш (live=false), поэтому достоверность готовности к записи до чтения выбранного устройства требует следующего блока. Также остаются гонки смены backend/режима/устройства и старого чтения после мутации, неизвестные срезы отключённых устройств и свежесть.
- Все 18 профильных E2E прошли: пять новых сценариев повреждённого контейнера/правила/дублей/сервера с первоначальной ошибкой, повтором, последующим отказом/сохранением данных и повреждённым подтверждением сохранения; 13 прежних операций/замка/таблицы. TypeScript и production build прошли (34 страницы). Новые схема/тест lint-чистые; исходные 2 ошибки/13 предупреждений страницы совпадают с HEAD. Diff-check прошёл. Последний backend build — 9.32, backend не изменён.
- Следующий точный шаг: поколения загрузки Filters, инвалидировать старое чтение перед мутацией, изолировать backend/режим и устройство; не разрешать запись до подтверждённого актуального чтения selectedServerId. Проверить поздний успех/ошибку, смену устройства и первоначальный backend-кэш. Затем схемы/ошибки шлюзов и свежесть. Реальные RouterOS/JWT/БД не проверены; этап 9 открыт.
- Квота: 69% пятичасовой/11% недельной на старте, 72%/11% после профильных проверок. Локальный коммит без push; чужие изменения сохранены.
## Блок 9.39 — общий замок обновления и восстановления Filters (2026-09-30)
- refreshFromRouter и restoreRevision используют общий синхронный applyLock. Восстановление удерживает его до чтения правил (если ответ не содержит rules) и истории. Повторы/конфликты отклоняются, выбор устройства и действия таблицы удерживаются; finally освобождает замок после успеха/ошибки. Открытие/повтор загрузки истории также проверяют замок.
+4
View File
@@ -197,3 +197,7 @@ Backend сообщает failures по срезу, неполный snapshot о
## Блок 9.39 — общий замок Filters (2026-09-30)
Обновление/восстановление используют applyLock, восстановление держит его до чтения правил/истории. Ошибка чтения сохраняет подтверждение и последние строки; чужой ruleset и stale отклоняются. 15 разных E2E в нескольких прогонах и TypeScript прошли; новых lint замечаний нет. Далее — схемы/первоначальная загрузка без моковой подмены, затем гонки и свежесть. Реальные устройства не проверены; frontend build — 9.31, backend — 9.32.
## Блок 9.40 — загрузка Filters (2026-09-30)
Схемы валидируют правила/наборы/подтверждения, live-ошибка больше не подставляет моки, есть первоначальная ошибка/повтор и сохранение последних правил. 18 E2E, TypeScript и frontend build (34 страницы) прошли; новых lint замечаний нет. Далее — поколения чтения/изоляция контекста и блокировка записи до свежего чтения выбранного устройства. Начальный GET отдаёт backend-кэш; GRE/recursive payload и ошибки ещё не приняты. Реальные устройства не проверены; backend build — 9.32.
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod"
const ruleSchema = z.object({
id: z.string().min(1), community: z.string(), communityName: z.string().optional(),
action: z.enum(["route", "blackhole"]).optional(), gateway: z.string(), gatewayTunnelId: z.string(), description: z.string(),
})
export const filterRulesSchema = z.array(ruleSchema).refine(rows => new Set(rows.map(r => r.id)).size === rows.length)
export const filtersResponseSchema = z.object({
rulesets: z.array(z.object({ serverId: z.string().min(1), rules: filterRulesSchema }))
.refine(rows => new Set(rows.map(r => r.serverId)).size === rows.length),
live: z.boolean().optional(), stale: z.boolean().optional(), error: z.string().optional(),
})
export const filtersMutationSchema = z.object({ ok: z.literal(true), rules: filterRulesSchema.optional() })
+69
View File
@@ -0,0 +1,69 @@
import { test, expect } from "@playwright/test"
for (const invalid of ["container", "rule", "duplicate", "server"] as const) {
test(`Filters: повреждённый ответ ${invalid}, первый сбой, повтор и сохранение данных`, async ({ page }) => {
let phase = 0
const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Подтверждённые данные" }
const server = { id: 1, name: "Реальный каталог тест", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }
const good = { rulesets: [{ serverId: "1", rules: [rule] }], greTunnels: [], live: true }
const bad = invalid === "container" ? { rulesets: {} } : invalid === "rule" ? { ...good, rulesets: [{ serverId: "1", rules: [{ ...rule, description: null }] }] } : { ...good, rulesets: [{ serverId: "1", rules: [rule, rule] }] }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/filters/rules" && phase === 2 && invalid === "server") return route.fulfill({ status: 503, json: { error: "Опрос недоступен тест" } })
return route.fulfill({ json: path === "/api/filters/rules" ? phase !== 1 && invalid !== "server" ? bad : good
: path === "/api/servers" ? [phase === 0 && invalid === "server" ? { ...server, host: null } : server]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить Filters" })).toBeVisible()
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
await expect(page.getByPlaceholder("Community, gateway, описание…")).toHaveCount(0)
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toHaveCount(0)
phase = 1
await page.getByRole("button", { name: "Повторить загрузку", exact: true }).click()
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
await expect(rows.first()).toContainText("Подтверждённые данные")
await expect(page.getByRole("option", { name: /Реальный каталог тест/ })).toHaveCount(1)
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
phase = 2
const refresh = page.getByRole("button", { name: "Обновить", exact: true })
await refresh.click()
await expect(refresh).toBeEnabled()
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeDisabled()
await expect(rows).toHaveCount(1)
await expect(rows.first()).toContainText("Подтверждённые данные")
phase = 1
await refresh.click()
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
})
}
test("Filters: повреждённые подтверждённые правила не закрывают форму и не заменяют таблицу", async ({ page }) => {
const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Прежние данные" }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
if (path === "/api/filters/rules" && route.request().method() === "PUT") return route.fulfill({ json: { ok: true, rules: [{ ...rule, gateway: null }] } })
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], greTunnels: [], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }]
: path === "/api/auth/config" ? { required: false }
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [] } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(1)
await rows.first().hover()
await rows.first().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("Сервер не подтвердил применение правил")
await expect(dialog.getByRole("textbox", { name: "Описание правила" })).toHaveValue("Сохранить ввод")
await expect(rows.first()).toContainText("Прежние данные")
await expect(page.getByText("Правила применены на роутер", { exact: true })).toHaveCount(0)
})