fix(ui): изолировать операции восстановления Filters

This commit is contained in:
Denozordec committed 2026-09-30 11:42:57 +07:00
1 parent 7554f1804b
commit 1fba16e1f2
5 files changed
+145 -15

No files matched your search

+23 -13
View File
@@ -1426,12 +1426,14 @@ export default function FiltersPage() {
}, [isLive, apiFetch])
const loadLiveRules = useCallback(async (serverId: string) => {
if (!isLive || !serverId) return
if (!isLive || !serverId) return false
try {
const fresh = await apiFetch<LiveFiltersResponse>(
`/api/filters/rules?serverId=${encodeURIComponent(serverId)}`,
)
const liveRules = fresh.rulesets.find((r) => r.serverId === serverId)?.rules ?? fresh.rulesets[0]?.rules ?? []
const ruleset = fresh.rulesets.find(r => r.serverId === serverId)
if (!ruleset || fresh.stale) throw new Error("Не удалось получить актуальные правила выбранного устройства")
const liveRules = ruleset.rules
setRulesets((prev) => {
const has = prev.some((rs) => rs.serverId === serverId)
if (!has) return [...prev, { serverId, rules: liveRules }]
@@ -1441,9 +1443,11 @@ export default function FiltersPage() {
if (fresh.greTunnels?.length) {
setGreByServer((prev) => ({ ...prev, [serverId]: fresh.greTunnels }))
}
return true
} catch (err) {
setLiveStale(true)
toast.error("Не удалось прочитать правила с роутера", { description: String(err) })
return false
}
}, [isLive, apiFetch])
@@ -1540,7 +1544,8 @@ export default function FiltersPage() {
}, [isLive, apiFetch, applyBusy, historyRestoring, liveStale, liveLoadState])
const refreshFromRouter = useCallback(async () => {
if (!isLive || !selectedServerId || applyBusy) return
if (!isLive || !selectedServerId || applyLock.current || applyBusy || historyRestoring) return
applyLock.current = true
setApplyBusy(true)
try {
await loadLiveRules(selectedServerId)
@@ -1549,9 +1554,10 @@ export default function FiltersPage() {
ensureRecursiveRoutes(selectedServerId),
])
} finally {
applyLock.current = false
setApplyBusy(false)
}
}, [isLive, selectedServerId, applyBusy, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes])
}, [isLive, selectedServerId, applyBusy, historyRestoring, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes])
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
@@ -1575,7 +1581,10 @@ export default function FiltersPage() {
}, [isLive, selectedServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !selectedServerId) return
if (!isLive || !selectedServerId) throw new Error("Выберите устройство в режиме живых данных")
if (applyLock.current || applyBusy || historyRestoring) throw new Error("Дождитесь завершения текущей операции")
applyLock.current = true
setApplyBusy(true)
setHistoryRestoring(true)
try {
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>(
@@ -1586,17 +1595,17 @@ export default function FiltersPage() {
if (res.rules) {
setRulesets((p) => p.map((rs) => rs.serverId === selectedServerId ? { ...rs, rules: res.rules ?? [] } : rs))
} else {
await loadLiveRules(selectedServerId)
if (!await loadLiveRules(selectedServerId)) throw new Error("Версия применена, но актуальные правила не прочитаны — обновите данные")
}
setLiveStale(false)
toast.success("Версия применена на роутер")
await loadRevisions()
} catch (err) {
throw err
toast.success("Версия применена на роутер")
} finally {
applyLock.current = false
setApplyBusy(false)
setHistoryRestoring(false)
}
}, [isLive, selectedServerId, apiFetch, loadLiveRules, loadRevisions])
}, [isLive, selectedServerId, apiFetch, applyBusy, historyRestoring, loadLiveRules, loadRevisions])
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
const current = rulesets.find((rs) => rs.serverId === serverId)?.rules ?? []
@@ -1738,7 +1747,7 @@ export default function FiltersPage() {
variant="outline"
size="sm"
onClick={() => void refreshFromRouter()}
disabled={applyBusy}
disabled={applyBusy || historyRestoring}
title="Прочитать актуальные правила с роутера"
>
<RefreshCwIcon className={cn("size-4", applyBusy && "animate-spin")} />
@@ -1748,10 +1757,11 @@ export default function FiltersPage() {
variant="outline"
size="sm"
onClick={() => {
if (applyLock.current) return
setHistoryOpen(true)
void loadRevisions()
}}
disabled={applyBusy}
disabled={applyBusy || historyRestoring}
title="История версий и откат на CHR"
>
<HistoryIcon className="size-4" />
@@ -2000,7 +2010,7 @@ export default function FiltersPage() {
open={historyOpen}
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
loadError={historyError}
onRetry={() => { void loadRevisions() }}
onRetry={() => { if (!applyLock.current) void loadRevisions() }}
title="История фильтров"
itemLabel="правил"
revisions={revisions}
+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 — остаются явно зафиксированными, без заявления о реализации.
+9 -1
View File
@@ -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.38 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.39 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 9.39 — общий замок обновления и восстановления Filters (2026-09-30)
- refreshFromRouter и restoreRevision используют общий синхронный applyLock. Восстановление удерживает его до чтения правил (если ответ не содержит rules) и истории. Повторы/конфликты отклоняются, выбор устройства и действия таблицы удерживаются; finally освобождает замок после успеха/ошибки. Открытие/повтор загрузки истории также проверяют замок.
- loadLiveRules возвращает признак успешного чтения и требует ruleset выбранного serverId, без подстановки первого чужого набора; stale отклоняется. При отказе сохраняется последний список и readonly-состояние. Восстановление с ok=true без правил, но неудачным чтением, сохраняет подтверждение с сообщением «Версия применена, но актуальные правила не прочитаны» и не показывает уведомление полного успеха. После успешного чтения данные снова доступны для операций. При ошибке самой истории её существующий alert остаётся; подтверждение применения относится к ответу restore и данным, а не к доступности каталога ревизий.
- Прошли 15 разных E2E в нескольких прогонах: девять прежних операций/таблицы, два прежних сценария истории, три восстановления (403, HTTP/чужой ruleset/stale при повторном чтении, задержка чтения/истории, двойное нажатие, disabled конфликтов, освобождение замка) и двойное обновление с ошибкой/повтором. Первые прогоны новых тестов исправлены по селекторам: два alert в подтверждении и фоновые кнопки, скрытые модальным диалогом от accessibility tree. Финальные четыре профильных теста прошли. TypeScript и lint нового теста прошли; исходные 2 ошибки/13 предупреждений страницы совпадают с HEAD. Последние build: frontend — 9.31, backend — 9.32, не повторялись.
- Следующий точный шаг: схемы ответов Filters и достоверность первоначальной загрузки — убрать подмену live-ошибки встроенными моками, сохранять реальные последние данные, различать начальную ошибку и пустой список, дать повтор. Затем поколения чтения/смены backend/режима и свежесть; маппинг шлюзов/реальные RouterOS/JWT/БД ещё требуют приёмки. Структура payload rules пока не валидируется, поэтому malformed rules остаются риском. Этап 9 открыт.
- Квота: 66% пятичасовой/10% недельной на старте, 68%/11% на последней проверке перед журналированием. Локальный коммит без push; чужие изменения сохранены.
## Блок 9.38 — перемещение Filters и порядок в поиске (2026-09-30)
- Перемещение работает по ID в полном наборе устройства, проверяет обе границы и ожидает applyRules. До ok=true порядок не меняется; отказ сохраняет порядок и показывает ошибку над таблицей. Синхронный moveLock исключает повторы, status сообщает ожидание; общий applyLock сохраняется.
+4
View File
@@ -193,3 +193,7 @@ Backend сообщает failures по срезу, неполный snapshot о
## Блок 9.38 — перемещение Filters (2026-09-30)
Перемещение по ID ждёт подтверждения и сохраняет порядок/ошибку при отказе. Приоритет и границы поиска берутся из полного списка. Действия таблицы блокируются при операции, повторы защищены синхронным замком. Девять E2E и TypeScript прошли; новых lint замечаний нет. Далее — общий замок восстановления/обновления, затем схемы/гонки/достоверность загрузки. Реальные устройства не проверены; frontend build — 9.31, backend — 9.32.
## Блок 9.39 — общий замок Filters (2026-09-30)
Обновление/восстановление используют applyLock, восстановление держит его до чтения правил/истории. Ошибка чтения сохраняет подтверждение и последние строки; чужой ruleset и stale отклоняются. 15 разных E2E в нескольких прогонах и TypeScript прошли; новых lint замечаний нет. Далее — схемы/первоначальная загрузка без моковой подмены, затем гонки и свежесть. Реальные устройства не проверены; frontend build — 9.31, backend — 9.32.
+108
View File
@@ -0,0 +1,108 @@
import { test, expect } from "@playwright/test"
for (const failure of ["http", "wrong-server", "stale"] as const) {
test(`Filters: замок восстановления и отказ чтения ${failure}`, async ({ page }) => {
const rule = { id: "rule-1", community: "65001:101", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "Правило" }
let restores = 0, revisionReads = 0, readsAfterRestore = 0
let releaseRead: (() => void) | undefined, releaseHistory: (() => void) | undefined
const readGate = new Promise<void>(resolve => { releaseRead = resolve })
const historyGate = new Promise<void>(resolve => { releaseHistory = resolve })
let historyWaiting = false
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.endsWith("/restore")) {
restores++
expect(route.request().postDataJSON()).toEqual({ serverId: "1" })
if (restores === 1) return route.fulfill({ status: 403, json: { error: "Откат запрещён тест" } })
return route.fulfill({ json: { ok: true } })
}
if (path === "/api/filters/revisions") {
revisionReads++
if (restores === 3) { historyWaiting = true; await historyGate }
return route.fulfill({ json: { revisions: [{ id: "rev/1", serverId: "1", section: "filters", source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 }] } })
}
if (path === "/api/filters/rules" && restores >= 2) {
readsAfterRestore++
if (restores === 2) {
if (failure === "http") return route.fulfill({ status: 503, json: { error: "Чтение недоступно тест" } })
return route.fulfill({ json: { rulesets: [{ serverId: failure === "wrong-server" ? "2" : "1", rules: [] }], greTunnels: [], stale: failure === "stale" } })
}
await readGate
}
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 page.getByRole("button", { name: "История", exact: true }).click()
const sheet = page.getByRole("dialog")
await sheet.getByRole("button", { name: "Откатить", exact: true }).click()
const confirm = page.getByRole("alertdialog")
const restore = confirm.getByRole("button", { name: "Откатить", exact: true })
await restore.click()
await expect(confirm.getByRole("alert").filter({ hasText: "Откат запрещён тест" })).toBeVisible()
await restore.click()
await expect(confirm.getByRole("alert").filter({ hasText: "Версия применена, но актуальные правила не прочитаны" })).toBeVisible()
await expect(rows).toHaveCount(1)
await expect(page.getByText("Версия применена на роутер", { exact: true })).toHaveCount(0)
await expect(restore).toBeEnabled()
await restore.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => restores).toBe(3)
await expect.poll(() => readsAfterRestore).toBe(2)
await expect(restore).toBeDisabled()
await expect(confirm.getByRole("button", { name: "Отмена" })).toBeDisabled()
for (const name of ["Обновить", "История", "Новое правило", "Копировать", "Редактировать правило", "Удалить правило"]) await expect(page.getByRole("button", { name, exact: true, includeHidden: true })).toBeDisabled()
await page.keyboard.press("Escape")
await expect(confirm).toBeVisible()
releaseRead!()
await expect.poll(() => historyWaiting).toBe(true)
await expect(restore).toBeDisabled()
await expect(page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })).toBeDisabled()
await expect(page.getByText("Версия применена на роутер", { exact: true })).toHaveCount(0)
releaseHistory!()
await expect(confirm).toHaveCount(0)
await expect(page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })).toBeEnabled()
await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeEnabled()
expect(restores).toBe(3)
expect(revisionReads).toBe(2)
})
}
test("Filters: двойное обновление удерживает общий замок и освобождает его после ошибки", async ({ page }) => {
let deviceReads = 0
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
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/**", async route => {
const url = new URL(route.request().url()), path = url.pathname
if (path === "/api/filters/rules" && url.searchParams.has("serverId")) {
deviceReads++
if (deviceReads === 2) { await gate; return route.fulfill({ status: 503, json: { error: "Сбой обновления тест" } }) }
}
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")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect.poll(() => deviceReads).toBe(1)
const refresh = page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })
await refresh.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => deviceReads).toBe(2)
for (const name of ["Обновить", "История", "Новое правило", "Копировать", "Редактировать правило", "Удалить правило"]) await expect(page.getByRole("button", { name, exact: true, includeHidden: true })).toBeDisabled()
release!()
await expect(refresh).toBeEnabled()
await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeDisabled()
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await refresh.click()
await expect.poll(() => deviceReads).toBe(3)
await expect(page.getByRole("button", { name: "Новое правило", exact: true, includeHidden: true })).toBeEnabled()
})