fix(ui): отклонять устаревшее чтение Filters
This commit is contained in:
1 parent
1c7ffa07a8
commit
24cc66d8d2
5 files changed
+104
-8
No files matched your search
@@ -1349,6 +1349,9 @@ export default function FiltersPage() {
|
||||
const [moveError, setMoveError] = useState<string | null>(null)
|
||||
const moveLock = useRef(false)
|
||||
const [liveStale, setLiveStale] = useState(false)
|
||||
const readEpoch = useRef(0)
|
||||
const [readyServerId, setReadyServerId] = useState<string | null>(null)
|
||||
const [rulesReading, setRulesReading] = useState(false)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||
@@ -1371,6 +1374,8 @@ export default function FiltersPage() {
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
readEpoch.current += 1
|
||||
setReadyServerId(null)
|
||||
setLiveLoadState("loading")
|
||||
setLiveLoadError(null)
|
||||
Promise.all([
|
||||
@@ -1419,27 +1424,35 @@ export default function FiltersPage() {
|
||||
|
||||
const loadLiveRules = useCallback(async (serverId: string) => {
|
||||
if (!isLive || !serverId) return false
|
||||
const epoch = ++readEpoch.current
|
||||
setReadyServerId(null)
|
||||
setRulesReading(true)
|
||||
try {
|
||||
const payload = await apiFetch<unknown>(
|
||||
`/api/filters/rules?serverId=${encodeURIComponent(serverId)}`,
|
||||
)
|
||||
if (epoch !== readEpoch.current) return false
|
||||
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("Не удалось получить актуальные правила выбранного устройства")
|
||||
if (!ruleset || fresh.stale || fresh.live !== true) throw new Error("Не удалось получить актуальные правила выбранного устройства")
|
||||
const liveRules = ruleset.rules
|
||||
setRulesets((prev) => {
|
||||
const has = prev.some((rs) => rs.serverId === serverId)
|
||||
if (!has) return [...prev, { serverId, rules: liveRules }]
|
||||
return prev.map((rs) => rs.serverId === serverId ? { ...rs, rules: liveRules } : rs)
|
||||
})
|
||||
setLiveStale(Boolean(fresh.stale))
|
||||
setLiveStale(false)
|
||||
setReadyServerId(serverId)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (epoch !== readEpoch.current) return false
|
||||
setLiveStale(true)
|
||||
toast.error("Не удалось прочитать правила с роутера", { description: String(err) })
|
||||
return false
|
||||
} finally {
|
||||
if (epoch === readEpoch.current) setRulesReading(false)
|
||||
}
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
@@ -1457,7 +1470,7 @@ export default function FiltersPage() {
|
||||
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"))
|
||||
const mutationsLocked = movePending || (isLive && (applyBusy || historyRestoring || rulesReading || readyServerId !== selectedServerId || liveStale || liveLoadState !== "idle"))
|
||||
|
||||
const filterRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
allServers.map((s) => ({
|
||||
@@ -1475,6 +1488,10 @@ export default function FiltersPage() {
|
||||
|
||||
const handleSelectServer = useCallback((id: string) => {
|
||||
if (applyLock.current) return
|
||||
readEpoch.current += 1
|
||||
setReadyServerId(null)
|
||||
setRulesReading(false)
|
||||
setLiveStale(false)
|
||||
setSheetOpen(false)
|
||||
setPendingDelete(null)
|
||||
setDeleteError(null)
|
||||
@@ -1505,7 +1522,7 @@ export default function FiltersPage() {
|
||||
source: "apply" | "copy" = "apply",
|
||||
) => {
|
||||
if (applyLock.current || applyBusy || historyRestoring) return { ok: false, error: "Дождитесь завершения текущей операции" }
|
||||
if (isLive && (liveStale || liveLoadState !== "idle")) {
|
||||
if (isLive && (liveStale || liveLoadState !== "idle" || (serverId === selectedServerId && readyServerId !== serverId))) {
|
||||
const error = "Роутер недоступен — изменения заблокированы"
|
||||
toast.error(error)
|
||||
return { ok: false, error }
|
||||
@@ -1515,6 +1532,8 @@ export default function FiltersPage() {
|
||||
return { ok: true }
|
||||
}
|
||||
applyLock.current = true
|
||||
readEpoch.current += 1
|
||||
setRulesReading(false)
|
||||
setApplyBusy(true)
|
||||
try {
|
||||
const payload = await apiFetch<unknown>("/api/filters/rules", {
|
||||
@@ -1535,7 +1554,7 @@ export default function FiltersPage() {
|
||||
applyLock.current = false
|
||||
setApplyBusy(false)
|
||||
}
|
||||
}, [isLive, apiFetch, applyBusy, historyRestoring, liveStale, liveLoadState])
|
||||
}, [isLive, apiFetch, applyBusy, historyRestoring, liveStale, liveLoadState, readyServerId, selectedServerId])
|
||||
|
||||
const refreshFromRouter = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId || applyLock.current || applyBusy || historyRestoring) return
|
||||
@@ -1553,7 +1572,7 @@ export default function FiltersPage() {
|
||||
}
|
||||
}, [isLive, selectedServerId, applyBusy, historyRestoring, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes])
|
||||
|
||||
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
|
||||
useEffect(() => () => { historyEpoch.current += 1; readEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
|
||||
|
||||
const loadRevisions = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
@@ -1580,6 +1599,8 @@ export default function FiltersPage() {
|
||||
applyLock.current = true
|
||||
setApplyBusy(true)
|
||||
setHistoryRestoring(true)
|
||||
readEpoch.current += 1
|
||||
setRulesReading(false)
|
||||
try {
|
||||
const payload = await apiFetch<unknown>(
|
||||
`/api/filters/revisions/${encodeURIComponent(id)}/restore`,
|
||||
@@ -1594,6 +1615,7 @@ export default function FiltersPage() {
|
||||
if (!await loadLiveRules(selectedServerId)) throw new Error("Версия применена, но актуальные правила не прочитаны — обновите данные")
|
||||
}
|
||||
setLiveStale(false)
|
||||
setReadyServerId(selectedServerId)
|
||||
await loadRevisions()
|
||||
toast.success("Версия применена на роутер")
|
||||
} finally {
|
||||
@@ -1799,6 +1821,7 @@ export default function FiltersPage() {
|
||||
<Button variant="outline" size="sm" onClick={() => setLoadAttempt(n => n + 1)}>Повторить загрузку</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">
|
||||
<AlertTriangleIcon className="size-3.5 shrink-0" />
|
||||
|
||||
+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 — остаются явно зафиксированными, без заявления о реализации.
|
||||
|
||||
|
||||
@@ -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.40 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.41 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.41 — актуальное чтение выбранного устройства Filters (2026-09-30)
|
||||
|
||||
- Запись выбранного устройства доступна после успешного чтения его ruleset с live=true и без stale. readyServerId должен совпадать с выбранным ID, до ответа действия readonly; статус объясняет ожидание. Исходный GET без serverId отдаёт кэш и сам не разрешает запись. Восстановление с подтверждёнными rules либо успешным повторным чтением устанавливает готовность устройства.
|
||||
- readEpoch отклоняет поздний успех/ошибку чтения. Поколение меняется при выборе устройства, первоначальной загрузке, начале применения/восстановления и cleanup контекста/размонтирования. Старое чтение не изменяет список, готовность, stale/статус и не показывает ошибку. Пока ручное обновление выполняется, общий applyLock блокирует записи; формы ожидают актуальное состояние.
|
||||
- Все 20 E2E прошли: два новых сценария задержанного чтения первого устройства, readonly кэша, переключения на второе, подтверждённого удаления и позднего успеха/ошибки первого запроса; 18 прежних сценариев. После уточнения требования live=true повторены шесть профильных сценариев гонок/истории — passed. TypeScript прошёл; новый тест lint-чистый, исходные 2 ошибки/13 предупреждений страницы совпадают с HEAD. Diff-check прошёл. Последний frontend build — 9.40, backend — 9.32; в блоке не повторялись.
|
||||
- Объём сознательно ограничен выбранным устройством. Следующий точный шаг: изолировать всё состояние Filters по backend/режиму и игнорировать завершения старых мутаций, затем перед копированием прочитать актуальный целевой ruleset и не объединять правила с первоначальным кэшем. Схемы/ошибки GRE/recursive, отключённые устройства, свежесть и реальные RouterOS/JWT/БД остаются открытыми; этап 9 не завершён.
|
||||
- Квота: 73% пятичасовой/11% недельной на старте, 75%/12% после проверок. Достигнут резерв 25%: новый блок не начат, проверки/журнал завершены. Локальный коммит без push; чужие изменения сохранены.
|
||||
|
||||
## Блок 9.40 — схемы и первоначальная загрузка Filters (2026-09-30)
|
||||
|
||||
- lib/filters-response.ts проверяет обязательные поля правила, action, уникальность ID в наборе и serverId в контейнере. Схемы используются в первоначальном GET, чтении устройства, PUT применения и POST восстановления. Ответ с ok=true, но повреждёнными rules отклоняется без замены таблицы/закрытия формы. Для каталога устройств переиспользуется общий существующий контракт firewallServersSchema; не добавлены новые зависимости.
|
||||
|
||||
@@ -201,3 +201,7 @@ Backend сообщает failures по срезу, неполный snapshot о
|
||||
## Блок 9.40 — загрузка Filters (2026-09-30)
|
||||
|
||||
Схемы валидируют правила/наборы/подтверждения, live-ошибка больше не подставляет моки, есть первоначальная ошибка/повтор и сохранение последних правил. 18 E2E, TypeScript и frontend build (34 страницы) прошли; новых lint замечаний нет. Далее — поколения чтения/изоляция контекста и блокировка записи до свежего чтения выбранного устройства. Начальный GET отдаёт backend-кэш; GRE/recursive payload и ошибки ещё не приняты. Реальные устройства не проверены; backend build — 9.32.
|
||||
|
||||
## Блок 9.41 — гонки чтения выбранного устройства Filters (2026-09-30)
|
||||
|
||||
Запись выбранного устройства требует успешного чтения live=true; кэш остаётся readonly до ответа. Поколение запросов отменяет старый успех/ошибку при переключении, применении и cleanup. 20 E2E и TypeScript прошли, шесть профильных сценариев повторены после уточнения live; новых lint замечаний нет. Далее — изоляция состояния backend/режима и старых мутаций, затем актуальное чтение цели копирования. GRE/recursive и свежесть ещё открыты. Остаток пятичасовой квоты 25%; новый блок не начат. Frontend build — 9.40, backend — 9.32.
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
|
||||
for (const outcome of ["success", "error"] as const) {
|
||||
test(`Filters: исходный кэш readonly и поздний ${outcome} предыдущего устройства отклонён`, async ({ page }) => {
|
||||
const makeRule = (id: string, community: string) => ({ id, community, gateway: "", gatewayTunnelId: "", action: "blackhole", description: id })
|
||||
const firstRules = [makeRule("cache-1", "65001:101")]
|
||||
let secondRules = [makeRule("device-2", "65002:201")]
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
let firstWaiting = false, firstFinished = false, writes = 0
|
||||
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" && route.request().method() === "PUT") {
|
||||
writes++
|
||||
const body = route.request().postDataJSON()
|
||||
expect(body.serverId).toBe("2")
|
||||
expect(body.rules).toEqual([])
|
||||
secondRules = body.rules
|
||||
return route.fulfill({ json: { ok: true, rules: [] } })
|
||||
}
|
||||
if (path === "/api/filters/rules" && url.searchParams.get("serverId") === "1") {
|
||||
firstWaiting = true
|
||||
await gate
|
||||
if (outcome === "error") await route.fulfill({ status: 503, json: { error: "Устаревший сбой первого устройства" } })
|
||||
else await route.fulfill({ json: { rulesets: [{ serverId: "1", rules: firstRules }], greTunnels: [], live: true } })
|
||||
firstFinished = true
|
||||
return
|
||||
}
|
||||
return route.fulfill({ json: path === "/api/filters/rules" ? url.searchParams.has("serverId") ? { rulesets: [{ serverId: "2", rules: secondRules }], greTunnels: [], live: true }
|
||||
: { rulesets: [{ serverId: "1", rules: firstRules }, { serverId: "2", rules: secondRules }], greTunnels: [], live: false, stale: false }
|
||||
: path === "/api/servers" ? [1, 2].map(id => ({ id, name: `Роутер ${id}`, host: `192.0.2.${id}`, 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.poll(() => firstWaiting).toBe(true)
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
await expect(page.getByRole("status")).toContainText("Чтение актуальных правил устройства")
|
||||
for (const name of ["Новое правило", "Копировать", "Редактировать правило", "Удалить правило"]) await expect(page.getByRole("button", { name, exact: true })).toBeDisabled()
|
||||
expect(writes).toBe(0)
|
||||
await page.getByRole("option", { name: /Роутер 2/ }).click()
|
||||
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
|
||||
const rows = page.locator("tr[data-row-id]")
|
||||
await expect(rows.first()).toContainText("65002:201")
|
||||
await rows.first().hover()
|
||||
await rows.first().getByRole("button", { name: "Удалить правило", exact: true }).click()
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: "Удалить", exact: true }).click()
|
||||
await expect(page.getByRole("alertdialog")).toHaveCount(0)
|
||||
await expect(rows).toHaveCount(0)
|
||||
expect(writes).toBe(1)
|
||||
release!()
|
||||
await expect.poll(() => firstFinished).toBe(true)
|
||||
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
|
||||
await expect(page.getByRole("option", { name: /Роутер 2/ })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(rows).toHaveCount(0)
|
||||
await expect(page.getByText("Не удалось прочитать правила с роутера", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("status").filter({ hasText: "Чтение актуальных правил устройства" })).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user