fix(ui): сохранять недоступные шлюзы Filters
This commit is contained in:
1 parent
860e88ed3e
commit
0ab5a23eb3
7 files changed
+149
-30
No files matched your search
+55
-26
@@ -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<string, Partial<Record<"gre" | "recursive", boolean>>>
|
||||
|
||||
/** 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<string, RecursiveRouteLite[]>
|
||||
ensureRecursiveFor?: (serverId: string) => void
|
||||
catalogReady?: GatewayReadiness
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set(sourceRules.map(r => r.id)))
|
||||
const [targetId, setTargetId] = useState<string | null>(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 (
|
||||
<Sheet open={open} onOpenChange={v => { if (!v) close() }}>
|
||||
@@ -1218,7 +1232,7 @@ function CopyRulesSheet({
|
||||
))}
|
||||
{mapping.some(m => !m.mapped) && (
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
Правила с пустым шлюзом нужно отредактировать после копирования
|
||||
Копирование недоступно, пока для каждого маршрута не найден шлюз целевого устройства
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1226,6 +1240,10 @@ function CopyRulesSheet({
|
||||
)}
|
||||
|
||||
</fieldset>
|
||||
{targetId && !mappingReady && <div className="px-5 py-3 flex flex-col gap-2">
|
||||
<p role="alert" className="text-sm text-destructive">Каталоги или сопоставление шлюзов не подтверждены. Копирование маршрутов заблокировано.</p>
|
||||
<Button variant="outline" disabled={copying} onClick={() => { for (const id of [sourceServerId, targetId]) { ensureGreFor?.(id); ensureRecursiveFor?.(id) } }}>Обновить шлюзы</Button>
|
||||
</div>}
|
||||
{copyError && <p role="alert" className="px-5 py-3 text-sm text-destructive">{copyError}</p>}
|
||||
{copying && <p role="status" className="px-5 py-2 text-sm text-muted-foreground">Ожидание подтверждения копирования…</p>}
|
||||
|
||||
@@ -1313,33 +1331,38 @@ function FiltersPage() {
|
||||
const [liveLoadError, setLiveLoadError] = useState<string | null>(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<GatewayReadiness>({})
|
||||
const [gatewayErrors, setGatewayErrors] = useState<Record<string, Partial<Record<"gre" | "recursive", string>>>>({})
|
||||
const gatewayEpoch = useRef<Record<string, number>>({})
|
||||
|
||||
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<unknown>(`${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<string | null> => {
|
||||
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() {
|
||||
<Button variant="outline" size="sm" onClick={() => setLoadAttempt(n => n + 1)}>Повторить загрузку</Button>
|
||||
</div>
|
||||
)}
|
||||
{isLive && Object.values(gatewayErrors[selectedServerId] ?? {}).some(Boolean) && <div className="border-b px-6 py-3 flex items-center gap-3">
|
||||
<p role="alert" className="text-xs text-destructive">{Object.values(gatewayErrors[selectedServerId] ?? {}).filter(Boolean).join(" · ")}. Сохранены последние каталоги шлюзов.</p>
|
||||
<Button variant="outline" size="sm" disabled={applyBusy} onClick={() => { void ensureGreTunnels(selectedServerId); void ensureRecursiveRoutes(selectedServerId) }}>Обновить шлюзы</Button>
|
||||
</div>}
|
||||
{isLive && rulesReading && <p role="status" className="border-b px-6 py-2 text-xs text-muted-foreground">Чтение актуальных правил устройства… Изменения временно заблокированы.</p>}
|
||||
{isLive && liveStale && liveLoadState !== "error" && (
|
||||
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-6 py-2.5 text-xs text-amber-700 dark:text-amber-400 flex items-center gap-2">
|
||||
@@ -2058,6 +2086,7 @@ function FiltersPage() {
|
||||
ensureGreFor={ensureGreTunnels}
|
||||
recRoutesByServer={recRoutesByServer}
|
||||
ensureRecursiveFor={ensureRecursiveRoutes}
|
||||
catalogReady={isLive ? gatewayReady : undefined}
|
||||
/>
|
||||
|
||||
<ConfigHistorySheet
|
||||
|
||||
@@ -472,8 +472,9 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
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 выбранного устройства недоступен" })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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 сохраняется прежняя локальная операция.
|
||||
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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<void>(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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user