fix(ui): подтверждать перемещение правил Filters
This commit is contained in:
1 parent
c4011cfaff
commit
7554f1804b
6 files changed
+138
-24
No files matched your search
+31
-15
@@ -1361,6 +1361,9 @@ export default function FiltersPage() {
|
|||||||
const [deletePending, setDeletePending] = useState(false)
|
const [deletePending, setDeletePending] = useState(false)
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
const deleteLock = useRef(false)
|
const deleteLock = useRef(false)
|
||||||
|
const [movePending, setMovePending] = useState(false)
|
||||||
|
const [moveError, setMoveError] = useState<string | null>(null)
|
||||||
|
const moveLock = useRef(false)
|
||||||
const [liveStale, setLiveStale] = useState(false)
|
const [liveStale, setLiveStale] = useState(false)
|
||||||
const [historyOpen, setHistoryOpen] = useState(false)
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
const [historyLoading, setHistoryLoading] = useState(false)
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
@@ -1458,7 +1461,7 @@ export default function FiltersPage() {
|
|||||||
const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? [])
|
const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? [])
|
||||||
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
|
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
|
||||||
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
|
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
|
||||||
const mutationsLocked = isLive && (applyBusy || historyRestoring || liveStale || liveLoadState !== "idle")
|
const mutationsLocked = movePending || (isLive && (applyBusy || historyRestoring || liveStale || liveLoadState !== "idle"))
|
||||||
|
|
||||||
const filterRailItems = useMemo<ServerTileItem[]>(() => (
|
const filterRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
allServers.map((s) => ({
|
allServers.map((s) => ({
|
||||||
@@ -1479,6 +1482,7 @@ export default function FiltersPage() {
|
|||||||
setSheetOpen(false)
|
setSheetOpen(false)
|
||||||
setPendingDelete(null)
|
setPendingDelete(null)
|
||||||
setDeleteError(null)
|
setDeleteError(null)
|
||||||
|
setMoveError(null)
|
||||||
setSelectedServerId(id)
|
setSelectedServerId(id)
|
||||||
setSearch("")
|
setSearch("")
|
||||||
}, [])
|
}, [])
|
||||||
@@ -1677,18 +1681,25 @@ export default function FiltersPage() {
|
|||||||
const result = await applyRules(targetServerId, next, "copy")
|
const result = await applyRules(targetServerId, next, "copy")
|
||||||
return result.ok ? null : result.error ?? "Не удалось скопировать правила"
|
return result.ok ? null : result.error ?? "Не удалось скопировать правила"
|
||||||
}, [rulesets, applyRules])
|
}, [rulesets, applyRules])
|
||||||
const handleMoveUp = (index: number) => {
|
const handleMove = async (id: string, direction: -1 | 1) => {
|
||||||
if (mutationsLocked || index === 0) return
|
if (mutationsLocked || applyLock.current || moveLock.current) return
|
||||||
updateRules(selectedServerId, rules => {
|
const index = currentRules.findIndex(r => r.id === id)
|
||||||
const n = [...rules]; [n[index - 1], n[index]] = [n[index], n[index - 1]]; return n
|
const destination = index + direction
|
||||||
})
|
if (index < 0 || destination < 0 || destination >= currentRules.length) return
|
||||||
}
|
moveLock.current = true
|
||||||
const handleMoveDown = (index: number) => {
|
setMovePending(true)
|
||||||
if (mutationsLocked) return
|
setMoveError(null)
|
||||||
updateRules(selectedServerId, rules => {
|
const next = [...currentRules]
|
||||||
if (index >= rules.length - 1) return rules
|
;[next[index], next[destination]] = [next[destination], next[index]]
|
||||||
const n = [...rules]; [n[index], n[index + 1]] = [n[index + 1], n[index]]; return n
|
try {
|
||||||
})
|
const result = await applyRules(selectedServerId, next)
|
||||||
|
if (!result.ok) setMoveError(result.error ?? "Не удалось переместить правило")
|
||||||
|
} catch (error) {
|
||||||
|
setMoveError(error instanceof Error ? error.message : "Не удалось переместить правило")
|
||||||
|
} finally {
|
||||||
|
moveLock.current = false
|
||||||
|
setMovePending(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLive && liveLoadState === "loading") {
|
if (isLive && liveLoadState === "loading") {
|
||||||
@@ -1868,6 +1879,9 @@ export default function FiltersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{movePending && <p role="status" className="px-5 py-2 text-sm text-muted-foreground">Ожидание подтверждения порядка…</p>}
|
||||||
|
{moveError && <p role="alert" className="px-5 py-2 text-sm text-destructive">{moveError}</p>}
|
||||||
|
|
||||||
{currentRules.length === 0 ? (
|
{currentRules.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -1896,6 +1910,8 @@ export default function FiltersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<FiltersDataGrid
|
<FiltersDataGrid
|
||||||
rules={search ? filteredRules : currentRules}
|
rules={search ? filteredRules : currentRules}
|
||||||
|
orderedRules={currentRules}
|
||||||
|
mutationsLocked={mutationsLocked}
|
||||||
tunnelsList={allTunnels}
|
tunnelsList={allTunnels}
|
||||||
serversList={allServers}
|
serversList={allServers}
|
||||||
communityNameMap={communityNameMap}
|
communityNameMap={communityNameMap}
|
||||||
@@ -1903,8 +1919,8 @@ export default function FiltersPage() {
|
|||||||
enableSorting={!!search}
|
enableSorting={!!search}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onMoveUp={(id) => handleMoveUp(currentRules.findIndex((r) => r.id === id))}
|
onMoveUp={(id) => { void handleMove(id, -1) }}
|
||||||
onMoveDown={(id) => handleMoveDown(currentRules.findIndex((r) => r.id === id))}
|
onMoveDown={(id) => { void handleMove(id, 1) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -145,9 +145,11 @@ function FilterGatewayCell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FilterRowActions({
|
function FilterRowActions({
|
||||||
|
disabled,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
|
disabled: boolean
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -158,6 +160,7 @@ function FilterRowActions({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
|
disabled={disabled}
|
||||||
aria-label="Редактировать правило"
|
aria-label="Редактировать правило"
|
||||||
>
|
>
|
||||||
<PencilIcon className="size-3.5" />
|
<PencilIcon className="size-3.5" />
|
||||||
@@ -167,6 +170,7 @@ function FilterRowActions({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="size-7 p-0 text-muted-foreground hover:text-destructive"
|
className="size-7 p-0 text-muted-foreground hover:text-destructive"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
|
disabled={disabled}
|
||||||
aria-label="Удалить правило"
|
aria-label="Удалить правило"
|
||||||
>
|
>
|
||||||
<TrashIcon className="size-3.5" />
|
<TrashIcon className="size-3.5" />
|
||||||
@@ -177,6 +181,8 @@ function FilterRowActions({
|
|||||||
|
|
||||||
interface FiltersDataGridProps {
|
interface FiltersDataGridProps {
|
||||||
rules: FilterRule[]
|
rules: FilterRule[]
|
||||||
|
orderedRules?: FilterRule[]
|
||||||
|
mutationsLocked?: boolean
|
||||||
tunnelsList: GreTunnel[]
|
tunnelsList: GreTunnel[]
|
||||||
serversList: Server[]
|
serversList: Server[]
|
||||||
communityNameMap: Record<string, string>
|
communityNameMap: Record<string, string>
|
||||||
@@ -190,6 +196,8 @@ interface FiltersDataGridProps {
|
|||||||
|
|
||||||
function FiltersDataGrid({
|
function FiltersDataGrid({
|
||||||
rules,
|
rules,
|
||||||
|
orderedRules = rules,
|
||||||
|
mutationsLocked = false,
|
||||||
tunnelsList,
|
tunnelsList,
|
||||||
serversList,
|
serversList,
|
||||||
communityNameMap,
|
communityNameMap,
|
||||||
@@ -206,14 +214,15 @@ function FiltersDataGrid({
|
|||||||
id: "priority",
|
id: "priority",
|
||||||
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row }) => {
|
||||||
const isLast = row.index === table.getRowModel().rows.length - 1
|
const index = orderedRules.findIndex(r => r.id === row.original.id)
|
||||||
|
const isLast = index === orderedRules.length - 1
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center text-[11px] font-mono text-muted-foreground/40">
|
<div className="flex items-center justify-center text-[11px] font-mono text-muted-foreground/40">
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<StarIcon className="size-3 text-amber-400 fill-amber-400" aria-label="Наивысший приоритет" />
|
<StarIcon className="size-3 text-amber-400 fill-amber-400" aria-label="Наивысший приоритет" />
|
||||||
) : (
|
) : (
|
||||||
<span>{row.index + 1}</span>
|
<span>{index + 1}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -225,15 +234,16 @@ function FiltersDataGrid({
|
|||||||
id: "reorder",
|
id: "reorder",
|
||||||
header: () => <span className="sr-only">Порядок</span>,
|
header: () => <span className="sr-only">Порядок</span>,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row }) => {
|
||||||
const isLast = row.index === table.getRowModel().rows.length - 1
|
const index = orderedRules.findIndex(r => r.id === row.original.id)
|
||||||
|
const isLast = index === orderedRules.length - 1
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-px opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
<div className="flex flex-col gap-px opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onMoveUp(row.original.id)}
|
onClick={() => onMoveUp(row.original.id)}
|
||||||
aria-label={`Поднять правило ${row.original.community}`}
|
aria-label={`Поднять правило ${row.original.community}`}
|
||||||
disabled={row.index === 0}
|
disabled={mutationsLocked || index <= 0}
|
||||||
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
|
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
|
||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-3" />
|
<ChevronUpIcon className="size-3" />
|
||||||
@@ -242,7 +252,7 @@ function FiltersDataGrid({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onMoveDown(row.original.id)}
|
onClick={() => onMoveDown(row.original.id)}
|
||||||
aria-label={`Опустить правило ${row.original.community}`}
|
aria-label={`Опустить правило ${row.original.community}`}
|
||||||
disabled={isLast}
|
disabled={mutationsLocked || index < 0 || isLast}
|
||||||
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
|
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-3" />
|
<ChevronDownIcon className="size-3" />
|
||||||
@@ -320,6 +330,7 @@ function FiltersDataGrid({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<FilterRowActions
|
<FilterRowActions
|
||||||
|
disabled={mutationsLocked}
|
||||||
onEdit={() => onEdit(row.original)}
|
onEdit={() => onEdit(row.original)}
|
||||||
onDelete={() => onDelete(row.original.id)}
|
onDelete={() => onDelete(row.original.id)}
|
||||||
/>
|
/>
|
||||||
@@ -330,6 +341,8 @@ function FiltersDataGrid({
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
communityNameMap,
|
communityNameMap,
|
||||||
|
orderedRules,
|
||||||
|
mutationsLocked,
|
||||||
enableSorting,
|
enableSorting,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
|||||||
+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; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,20 @@
|
|||||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.37 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.38 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 9.38 — перемещение Filters и порядок в поиске (2026-09-30)
|
||||||
|
|
||||||
|
- Перемещение работает по ID в полном наборе устройства, проверяет обе границы и ожидает applyRules. До ok=true порядок не меняется; отказ сохраняет порядок и показывает ошибку над таблицей. Синхронный moveLock исключает повторы, status сообщает ожидание; общий applyLock сохраняется.
|
||||||
|
- FiltersDataGrid принимает orderedRules и mutationsLocked: приоритет и границы перемещения вычисляются из полного списка даже при поиске/сортировке видимого среза. Во время записи/восстановления/недостоверных данных disabled перемещение, редактирование и удаление. Существующая композиция ReUI Data Grid сохранена, исходные обработчики индексов заменены одним обработчиком ID/направления.
|
||||||
|
- Девять E2E прошли: два новых направления из единственной найденной строки (полный приоритет, границы, задержка/двойное нажатие, 403, пустой ответ, ok=false, неизменный порядок, повтор с клавиатуры, успешный порядок после сброса поиска и блокировка конфликтующих действий), прежние операции и таблица. TypeScript прошёл; новый тест/таблица lint-чистые, исходные 2 ошибки/13 предупреждений страницы совпадают с HEAD. Diff-check прошёл. Последние build: frontend — 9.31, backend — 9.32; не повторялись.
|
||||||
|
- Следующий точный шаг: refreshFromRouter и restoreRevision подключить к общему синхронному замку; удерживать восстановление до чтения правил/истории, проверять отказ/повторы/конфликты и не считать ошибку чтения успешным восстановлением. Затем схемы и поколения загрузки Filters: первоначальная ошибка ещё подставляет моки, loadLiveRules может выбирать первый чужой ruleset и скрывает ошибку, смена backend/режима и ранее запущенные чтения ещё не защищены. Реальные RouterOS/JWT/БД не проверены; этап 9 открыт.
|
||||||
|
- Квота: 64% пятичасовой/10% недельной на старте, 65%/10% после проверок. Локальный коммит без push; чужие изменения сохранены.
|
||||||
|
|
||||||
## Блок 9.37 — подтверждённое удаление Filters (2026-09-30)
|
## Блок 9.37 — подтверждённое удаление Filters (2026-09-30)
|
||||||
|
|
||||||
- Удаление открывает существующий AlertDialog с Community и устройством вместо подтверждения вторым нажатием на безымянную иконку. Цель операции хранит serverId и правило; перед отправкой проверяется наличие правила. Общий PUT /api/filters/rules сохраняет остальные правила в прежнем порядке. Строка удаляется только после ok=true через applyRules, ошибка сохраняет диалог и показывает alert.
|
- Удаление открывает существующий AlertDialog с Community и устройством вместо подтверждения вторым нажатием на безымянную иконку. Цель операции хранит serverId и правило; перед отправкой проверяется наличие правила. Общий PUT /api/filters/rules сохраняет остальные правила в прежнем порядке. Строка удаляется только после ok=true через applyRules, ошибка сохраняет диалог и показывает alert.
|
||||||
|
|||||||
@@ -189,3 +189,7 @@ Backend сообщает failures по срезу, неполный snapshot о
|
|||||||
## Блок 9.37 — удаление Filters (2026-09-30)
|
## Блок 9.37 — удаление Filters (2026-09-30)
|
||||||
|
|
||||||
Удаление перенесено в AlertDialog с Community/устройством. Ошибка сохраняет подтверждение и строку, повторы/отмена/закрытие заблокированы до ответа, удаление применяется только после ok=true. Семь E2E и TypeScript прошли; новых lint замечаний нет. Далее — перемещение, затем общий замок восстановления/обновления и достоверность загрузки. Реальные устройства не проверены; frontend build — 9.31, backend — 9.32.
|
Удаление перенесено в AlertDialog с Community/устройством. Ошибка сохраняет подтверждение и строку, повторы/отмена/закрытие заблокированы до ответа, удаление применяется только после ok=true. Семь E2E и TypeScript прошли; новых lint замечаний нет. Далее — перемещение, затем общий замок восстановления/обновления и достоверность загрузки. Реальные устройства не проверены; frontend build — 9.31, backend — 9.32.
|
||||||
|
|
||||||
|
## Блок 9.38 — перемещение Filters (2026-09-30)
|
||||||
|
|
||||||
|
Перемещение по ID ждёт подтверждения и сохраняет порядок/ошибку при отказе. Приоритет и границы поиска берутся из полного списка. Действия таблицы блокируются при операции, повторы защищены синхронным замком. Девять E2E и TypeScript прошли; новых lint замечаний нет. Далее — общий замок восстановления/обновления, затем схемы/гонки/достоверность загрузки. Реальные устройства не проверены; frontend build — 9.31, backend — 9.32.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
import type { FilterRule } from "../../lib/data"
|
||||||
|
|
||||||
|
for (const direction of ["up", "down"] as const) {
|
||||||
|
test(`Filters: перемещение ${direction} в поиске сохраняет порядок при ошибке`, async ({ page }) => {
|
||||||
|
let rules: FilterRule[] = [1, 2, 3].map(id => ({ id: `rule-${id}`, community: `65001:10${id}`, gateway: "", gatewayTunnelId: "", action: "blackhole", description: `Правило ${id}` }))
|
||||||
|
const original = rules.map(r => r.id)
|
||||||
|
const confirmed = direction === "up" ? ["rule-2", "rule-1", "rule-3"] : ["rule-1", "rule-3", "rule-2"]
|
||||||
|
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.map((r: FilterRule) => r.id)).toEqual(confirmed)
|
||||||
|
expect(rules.map(r => r.id)).toEqual(original)
|
||||||
|
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]")
|
||||||
|
await expect(rows).toHaveCount(3)
|
||||||
|
await expect(page.getByRole("button", { name: "Поднять правило 65001:101", exact: true })).toBeDisabled()
|
||||||
|
await expect(page.getByRole("button", { name: "Опустить правило 65001:103", exact: true })).toBeDisabled()
|
||||||
|
const search = page.getByPlaceholder("Community, gateway, описание…")
|
||||||
|
await search.fill("Правило 2")
|
||||||
|
await expect(rows).toHaveCount(1)
|
||||||
|
await expect(rows.first().getByRole("cell").first()).toHaveText("2")
|
||||||
|
const up = page.getByRole("button", { name: "Поднять правило 65001:102", exact: true })
|
||||||
|
const down = page.getByRole("button", { name: "Опустить правило 65001:102", exact: true })
|
||||||
|
await expect(up).toBeEnabled()
|
||||||
|
await expect(down).toBeEnabled()
|
||||||
|
const move = direction === "up" ? up : down
|
||||||
|
await move.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
|
||||||
|
await expect.poll(() => attempts).toBe(1)
|
||||||
|
await expect(page.getByRole("status")).toContainText("Ожидание подтверждения порядка")
|
||||||
|
for (const control of [up, down, page.getByRole("button", { name: "Редактировать правило", exact: true }), page.getByRole("button", { name: "Удалить правило", exact: true }), page.getByRole("button", { name: "Новое правило", exact: true }), page.getByRole("button", { name: "Копировать", exact: true }), page.getByRole("button", { name: "Обновить", exact: true }), page.getByRole("button", { name: "История", exact: true })]) await expect(control).toBeDisabled()
|
||||||
|
release!()
|
||||||
|
await expect(page.getByRole("alert").filter({ hasText: "Перемещение запрещено тест" })).toBeVisible()
|
||||||
|
await expect(rows.first().getByRole("cell").first()).toHaveText("2")
|
||||||
|
for (let attempt = 2; attempt <= 3; attempt++) {
|
||||||
|
await rows.first().hover()
|
||||||
|
await move.click()
|
||||||
|
await expect.poll(() => attempts).toBe(attempt)
|
||||||
|
await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил применение правил" })).toBeVisible()
|
||||||
|
await expect(rows.first().getByRole("cell").first()).toHaveText("2")
|
||||||
|
await expect(page.getByText("Правила применены на роутер", { exact: true })).toHaveCount(0)
|
||||||
|
}
|
||||||
|
await move.focus()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await expect.poll(() => attempts).toBe(4)
|
||||||
|
await expect(move).toBeDisabled()
|
||||||
|
await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил применение правил" })).toHaveCount(0)
|
||||||
|
await search.fill("")
|
||||||
|
await expect(rows).toHaveCount(3)
|
||||||
|
expect(await rows.evaluateAll(elements => elements.map(e => e.getAttribute("data-row-id")))).toEqual(confirmed)
|
||||||
|
await expect(page.getByRole("button", { name: `${direction === "up" ? "Поднять" : "Опустить"} правило 65001:102`, exact: true })).toBeDisabled()
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user