diff --git a/app/(main)/filters/page.tsx b/app/(main)/filters/page.tsx index dff71076..69f945bc 100644 --- a/app/(main)/filters/page.tsx +++ b/app/(main)/filters/page.tsx @@ -32,6 +32,7 @@ import { import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, } from "@/components/ui/sheet" +import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { toast } from "sonner" @@ -1356,6 +1357,10 @@ export default function FiltersPage() { const [copyOpen, setCopyOpen] = useState(false) const [applyBusy, setApplyBusy] = useState(false) const applyLock = useRef(false) + const [pendingDelete, setPendingDelete] = useState<{ serverId: string; serverName: string; rule: FilterRule } | null>(null) + const [deletePending, setDeletePending] = useState(false) + const [deleteError, setDeleteError] = useState(null) + const deleteLock = useRef(false) const [liveStale, setLiveStale] = useState(false) const [historyOpen, setHistoryOpen] = useState(false) const [historyLoading, setHistoryLoading] = useState(false) @@ -1472,6 +1477,8 @@ export default function FiltersPage() { const handleSelectServer = useCallback((id: string) => { if (applyLock.current) return setSheetOpen(false) + setPendingDelete(null) + setDeleteError(null) setSelectedServerId(id) setSearch("") }, []) @@ -1632,8 +1639,36 @@ export default function FiltersPage() { } const handleDelete = (id: string) => { - if (mutationsLocked) return - updateRules(selectedServerId, rules => rules.filter(r => r.id !== id)) + if (mutationsLocked || applyLock.current) return + const rule = currentRules.find(r => r.id === id) + if (!rule || !selectedServer) return + setDeleteError(null) + setPendingDelete({ serverId: selectedServerId, serverName: selectedServer.name, rule }) + } + const confirmDelete = async () => { + if (!pendingDelete || deleteLock.current) return + if (mutationsLocked || applyLock.current) { + setDeleteError("Дождитесь завершения операции или обновите данные") + return + } + const current = rulesets.find(rs => rs.serverId === pendingDelete.serverId)?.rules + if (!current?.some(r => r.id === pendingDelete.rule.id)) { + setDeleteError("Правило отсутствует в текущем списке — обновите данные") + return + } + deleteLock.current = true + setDeletePending(true) + setDeleteError(null) + try { + const result = await applyRules(pendingDelete.serverId, current.filter(r => r.id !== pendingDelete.rule.id)) + if (!result.ok) setDeleteError(result.error ?? "Не удалось удалить правило") + else setPendingDelete(null) + } catch (error) { + setDeleteError(error instanceof Error ? error.message : "Не удалось удалить правило") + } finally { + deleteLock.current = false + setDeletePending(false) + } } const handleCopyRules = useCallback(async (targetServerId: string, rules: FilterRule[], mode: CopyMode) => { @@ -1884,6 +1919,26 @@ export default function FiltersPage() { + { + if (!open && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) } + }}> + + + Удалить правило Filters? + + Community {pendingDelete?.rule.community} · {pendingDelete?.serverName}. + Правило будет удалено из набора фильтров устройства. + + {deletePending &&

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

} + {deleteError &&

{deleteError}

} +
+ + Отмена + { e.preventDefault(); void confirmDelete() }}>Удалить + +
+
+ void onDelete: () => void }) { - const [confirmDel, setConfirmDel] = useState(false) return (
) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index a80dea4b..33d040fa 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -88,7 +88,7 @@ flowchart LR Ближайший оставшийся объём: -1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. В 9.33 состояние изолировано по backend/режиму; поздние операции старого экрана не запускают чтение и не показывают уведомления в новом контексте. В 9.34 показано время получения срезов Firewall: успешные обновляются, недоступные сохраняют прежнее время или остаются неизвестными. В 9.35 сохранение Filters ожидает ok=true, сохраняет ввод при ошибке и блокирует повторную отправку/закрытие; live-список меняется после подтверждения. В 9.36 копирование Filters ждёт подтверждения, сохраняет цель/правила/режим при ошибке и блокирует повторы/закрытие. Далее — удаление/перенос и общая блокировка восстановления/обновления; загрузка, схемы и гонки Filters остаются открытыми; ограничения счётчика, свежести и записи VXLAN остаются открытыми; загрузка данных IPsec и общая приёмка остаются открытыми. +1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. В 9.33 состояние изолировано по backend/режиму; поздние операции старого экрана не запускают чтение и не показывают уведомления в новом контексте. В 9.34 показано время получения срезов Firewall: успешные обновляются, недоступные сохраняют прежнее время или остаются неизвестными. В 9.35 сохранение Filters ожидает ok=true, сохраняет ввод при ошибке и блокирует повторную отправку/закрытие; live-список меняется после подтверждения. В 9.36 копирование Filters ждёт подтверждения, сохраняет цель/правила/режим при ошибке и блокирует повторы/закрытие. В 9.37 удаление Filters перенесено в диалог с сохранением ошибки, подтверждением API и защитой повторов. Далее — перемещение и общая блокировка восстановления/обновления; загрузка, схемы и гонки Filters остаются открытыми; ограничения счётчика, свежести и записи VXLAN остаются открытыми; загрузка данных IPsec и общая приёмка остаются открытыми. 2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения. 3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 94594dd2..943beda1 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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.36 — IPsec/GRE/VXLAN, Firewall, сохранение и копирование Filters | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.37 — IPsec/GRE/VXLAN, Firewall и операции Filters | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.37 — подтверждённое удаление Filters (2026-09-30) + +- Удаление открывает существующий AlertDialog с Community и устройством вместо подтверждения вторым нажатием на безымянную иконку. Цель операции хранит serverId и правило; перед отправкой проверяется наличие правила. Общий PUT /api/filters/rules сохраняет остальные правила в прежнем порядке. Строка удаляется только после ok=true через applyRules, ошибка сохраняет диалог и показывает alert. +- Синхронный deleteLock исключает повторы; отмена, действие удаления и Escape заблокированы до ответа, status сообщает ожидание. Повторное открытие очищает ошибку, смена устройства вне операции очищает подтверждение. Во время применения выбор устройства уже удерживается общим applyLock. Кнопка удаления получила доступное имя; ReUI Data Grid и существующие Base UI примитивы сохранены. +- Семь E2E прошли: удаление первого/последнего правила (отмена без запроса, двойное нажатие, задержка, 403, пустой ответ, ok=false, повтор, сохранение строки при ошибке и остальных правил после успеха), прежние сохранение/копирование и таблица. TypeScript прошёл. Таблица/новый тест lint-чистые; исходные 2 ошибки/13 предупреждений страницы совпадают с HEAD. Diff-check прошёл. Последние build: frontend — 9.31, backend — 9.32; не повторялись. +- Следующий точный шаг: перемещение Filters — проверить подтверждение и сохранение порядка при ошибках, границы, повторы и конфликты с другими операциями. Затем объединить замки восстановления/обновления, устранить гонки и подмену live-ошибки моками, добавить схемы. Реальные RouterOS/JWT/БД и загрузка актуального ruleset не приняты; этап 9 открыт. +- Квота: 62% пятичасовой/10% недельной на старте, 63%/10% после проверок. Локальный коммит без push; чужие изменения сохранены. + ## Блок 9.36 — подтверждённое копирование Filters (2026-09-30) - CopyRulesSheet ожидает applyRules через асинхронный onCopy. Преждевременные done и таймер закрытия удалены: форма закрывается после ok=true, при отказе сохраняет выбранные правила, целевой сервер и режим append/replace и показывает API-ошибку в alert. Общий applyRules продолжает обновлять целевой список только после подтверждения. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index cf1dc3b7..d6e0f77f 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -185,3 +185,7 @@ Backend сообщает failures по срезу, неполный snapshot о ## Блок 9.36 — копирование Filters (2026-09-30) Копирование append/replace ожидает подтверждения API; ошибка сохраняет правила, цель и режим. Повторы и закрытие заблокированы до ответа, преждевременный done/таймер удалены. Пять E2E, TypeScript и lint нового теста прошли; исходные замечания страницы сохранены. Далее — подтверждение удаления Filters, затем перемещение и общий замок. Гонки/схемы/загрузка целевого ruleset и реальный маппинг шлюзов остаются открытыми; frontend build — 9.31, backend — 9.32. + +## Блок 9.37 — удаление Filters (2026-09-30) + +Удаление перенесено в AlertDialog с Community/устройством. Ошибка сохраняет подтверждение и строку, повторы/отмена/закрытие заблокированы до ответа, удаление применяется только после ok=true. Семь E2E и TypeScript прошли; новых lint замечаний нет. Далее — перемещение, затем общий замок восстановления/обновления и достоверность загрузки. Реальные устройства не проверены; frontend build — 9.31, backend — 9.32. diff --git a/tests/e2e/filters-delete.spec.ts b/tests/e2e/filters-delete.spec.ts new file mode 100644 index 00000000..990a284d --- /dev/null +++ b/tests/e2e/filters-delete.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from "@playwright/test" +import type { FilterRule } from "../../lib/data" + +for (const deletingId of ["rule-1", "rule-2"]) { + test(`Filters: удаление ${deletingId} сохраняет подтверждение при ошибке`, async ({ page }) => { + let rules: FilterRule[] = [1, 2].map(id => ({ id: `rule-${id}`, community: `65001:10${id}`, gateway: "", gatewayTunnelId: "", action: "blackhole", description: `Правило ${id}` })) + const deleting = rules.find(r => r.id === deletingId)! + const retained = rules.find(r => r.id !== deletingId)! + let attempts = 0 + let release: (() => void) | undefined + const gate = new Promise(resolve => { release = resolve }) + 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 === "/api/filters/rules" && route.request().method() === "PUT") { + attempts++ + const body = route.request().postDataJSON() + expect(body.serverId).toBe("1") + expect(body.source).toBe("apply") + expect(body.rules).toEqual([retained]) + if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Удаление запрещено тест" } }) } + if (attempts === 2) return route.fulfill({ json: {} }) + if (attempts === 3) return route.fulfill({ json: { ok: false } }) + rules = body.rules + return route.fulfill({ json: { ok: true, rules } }) + } + return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules }], greTunnels: [], live: true } + : path === "/api/servers" ? [{ id: 1, name: "Тестовый роутер", host: "192.0.2.1", type: "exit-node", site: "TEST", country: "RU", asn: "", enabled: true, 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]") + const deletingRow = rows.filter({ hasText: deleting.community }) + await expect(rows).toHaveCount(2) + const open = async () => { + await deletingRow.hover() + await deletingRow.getByRole("button", { name: "Удалить правило", exact: true }).click() + } + await open() + const dialog = page.getByRole("alertdialog") + await expect(dialog).toContainText(deleting.community) + await expect(dialog).toContainText("Тестовый роутер") + await dialog.getByRole("button", { name: "Отмена", exact: true }).click() + await expect(dialog).toHaveCount(0) + expect(attempts).toBe(0) + await open() + const remove = dialog.getByRole("button", { name: "Удалить", exact: true }) + await remove.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + await expect.poll(() => attempts).toBe(1) + await expect(remove).toBeDisabled() + await expect(dialog.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled() + await expect(dialog.getByRole("status")).toContainText("Ожидание подтверждения удаления") + await expect(rows).toHaveCount(2) + await page.keyboard.press("Escape") + await expect(dialog).toBeVisible() + release!() + await expect(dialog.getByRole("alert")).toContainText("Удаление запрещено тест") + await expect(deletingRow).toHaveCount(1) + await dialog.getByRole("button", { name: "Отмена", exact: true }).click() + await open() + await expect(dialog.getByRole("alert")).toHaveCount(0) + for (let attempt = 2; attempt <= 3; attempt++) { + await remove.click() + await expect.poll(() => attempts).toBe(attempt) + await expect(dialog.getByRole("alert")).toContainText("Сервер не подтвердил применение правил") + await expect(rows).toHaveCount(2) + await expect(page.getByText("Правила применены на роутер", { exact: true })).toHaveCount(0) + } + await remove.click() + await expect(dialog).toHaveCount(0) + expect(attempts).toBe(4) + await expect(deletingRow).toHaveCount(0) + await expect(rows).toHaveCount(1) + await expect(rows.first()).toContainText(retained.community) + }) +}