fix(ui): подтверждать удаление правил Filters

This commit is contained in:
Denozordec committed 2026-09-30 11:29:54 +07:00
1 parent 8412ba9bbe
commit c4011cfaff
6 files changed
+154 -19

No files matched your search

+57 -2
View File
@@ -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<string | null>(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() {
</div>
</ServerRailLayout>
<AlertDialog open={!!pendingDelete} onOpenChange={open => {
if (!open && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) }
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить правило Filters?</AlertDialogTitle>
<AlertDialogDescription>
Community {pendingDelete?.rule.community} · {pendingDelete?.serverName}.
Правило будет удалено из набора фильтров устройства.
</AlertDialogDescription>
{deletePending && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения удаления…</p>}
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePending}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={deletePending || mutationsLocked} onClick={e => { e.preventDefault(); void confirmDelete() }}>Удалить</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<RuleSheet
key={`${sheetOpen}-${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
open={sheetOpen}
+5 -15
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useMemo } from "react"
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import {
type ColumnDef,
@@ -19,7 +19,6 @@ import {
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import {
AlertCircleIcon,
ChevronDownIcon,
ChevronUpIcon,
PencilIcon,
@@ -152,7 +151,6 @@ function FilterRowActions({
onEdit: () => void
onDelete: () => void
}) {
const [confirmDel, setConfirmDel] = useState(false)
return (
<div className="flex items-center gap-0.5 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<Button
@@ -167,19 +165,11 @@ function FilterRowActions({
<Button
size="sm"
variant="ghost"
className={cn(
"size-7 p-0 transition-colors",
confirmDel
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
: "text-muted-foreground hover:text-destructive",
)}
onClick={() => {
if (!confirmDel) setConfirmDel(true)
else onDelete()
}}
onBlur={() => setConfirmDel(false)}
className="size-7 p-0 text-muted-foreground hover:text-destructive"
onClick={onDelete}
aria-label="Удалить правило"
>
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
<TrashIcon className="size-3.5" />
</Button>
</div>
)
+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.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 продолжает обновлять целевой список только после подтверждения.
+4
View File
@@ -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.
+78
View File
@@ -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<void>(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)
})
}