fix(ui): сохранить контекст и фокус форм Firewall без эффектов

This commit is contained in:
Denozordec committed 2026-10-04 16:56:15 +07:00
1 parent 21dc646553
commit ab56225a1a
6 files changed
+116 -75

No files matched your search

+51 -74
View File
@@ -323,34 +323,6 @@ const CHAIN_GROUPS: {
{ id: "simulator", label: "Симулятор", icon: <PlayIcon className="size-3.5" />, chains: [] },
]
// ─── Action styles ────────────────────────────────────────────────────────────
const ACTION_STYLES: Record<string, string> = {
accept: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
drop: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
reject: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
masquerade: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
"mark-routing": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
"mark-conn": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
"fasttrack-connection": "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
"dst-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
"src-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
"add-src-to-address-list": "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
}
const CHAIN_STYLES: Record<string, string> = {
forward: "bg-foreground/5 text-foreground/70",
input: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
output: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
srcnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
dstnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
prerouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
postrouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
"ip6-input": "bg-violet-500/10 text-violet-600 dark:text-violet-400",
"ip6-forward": "bg-foreground/5 text-foreground/70",
"ip6-output": "bg-sky-500/10 text-sky-600 dark:text-sky-400",
}
// ─── Default form ─────────────────────────────────────────────────────────────
const defaultForm = {
@@ -488,7 +460,7 @@ function generateRsc(rules: FirewallRule[], timestamp?: string): string {
// ─── Rule Sheet ───────────────────────────────────────────────────────────────
function RuleSheet({ open, onClose, initialRule, chainGroup, family, onSave, saving = false, error }: {
function RuleSheet({ open, onClose, initialRule, chainGroup, family, onSave, saving = false, error, returnFocus }: {
open: boolean
onClose: () => void
initialRule: Partial<FirewallRule> | null
@@ -497,27 +469,32 @@ function RuleSheet({ open, onClose, initialRule, chainGroup, family, onSave, sav
onSave: (payload: RuleWritePayload) => void
saving?: boolean
error?: string | null
returnFocus?: () => HTMLElement | null
}) {
const isNew = !initialRule?.id
const [form, setForm] = useState<RuleForm>(defaultForm)
const [formContext, setFormContext] = useState({ open: false, initialRule, chainGroup })
useEffect(() => {
if (!open) return
setForm({
...defaultForm,
chain: initialRule?.chain ?? (CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains[0] ?? "forward"),
action: (initialRule?.action as string) ?? defaultForm.action,
proto: initialRule?.proto ?? defaultForm.proto,
comment: initialRule?.comment ?? "",
enabled: initialRule?.enabled ?? true,
srcAddrList: initialRule?.src && initialRule.src !== "—" ? initialRule.src : "",
dstAddrList: initialRule?.dst && initialRule.dst !== "—" ? initialRule.dst : "",
dstPort: initialRule?.port && initialRule.port !== "—" ? initialRule.port : "",
inIface: initialRule?.iface && initialRule.iface !== "—" ? initialRule.iface : "",
log: initialRule?.log ?? false,
logPrefix: initialRule?.logPrefix ?? "",
})
}, [open, initialRule, chainGroup])
// Adjust this component before committing children with a stale draft.
if (formContext.open !== open || formContext.initialRule !== initialRule || formContext.chainGroup !== chainGroup) {
setFormContext({ open, initialRule, chainGroup })
if (open) {
setForm({
...defaultForm,
chain: initialRule?.chain ?? (CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains[0] ?? "forward"),
action: (initialRule?.action as string) ?? defaultForm.action,
proto: initialRule?.proto ?? defaultForm.proto,
comment: initialRule?.comment ?? "",
enabled: initialRule?.enabled ?? true,
srcAddrList: initialRule?.src && initialRule.src !== "—" ? initialRule.src : "",
dstAddrList: initialRule?.dst && initialRule.dst !== "—" ? initialRule.dst : "",
dstPort: initialRule?.port && initialRule.port !== "—" ? initialRule.port : "",
inIface: initialRule?.iface && initialRule.iface !== "—" ? initialRule.iface : "",
log: initialRule?.log ?? false,
logPrefix: initialRule?.logPrefix ?? "",
})
}
}
const set = <K extends keyof RuleForm>(k: K, v: RuleForm[K]) =>
setForm((f) => ({ ...f, [k]: v }))
@@ -534,7 +511,7 @@ function RuleSheet({ open, onClose, initialRule, chainGroup, family, onSave, sav
return (
<Sheet open={open} onOpenChange={(v) => { if (!v && !saving) onClose() }}>
<SheetContent showCloseButton={!saving} className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-lg">
<SheetContent finalFocus={returnFocus} showCloseButton={!saving} className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-lg">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<SheetTitle>{isNew ? "Новое правило" : "Редактировать правило"}</SheetTitle>
<SheetDescription>
@@ -750,9 +727,7 @@ function AddressListsTab({
return ["all", ...Array.from(s)]
}, [entries])
useEffect(() => {
if (listFilter !== "all" && !lists.includes(listFilter)) setListFilter("all")
}, [lists, listFilter])
if (listFilter !== "all" && !lists.includes(listFilter)) setListFilter("all")
const filtered = useMemo(() => {
return entries.filter((e) => {
@@ -793,7 +768,7 @@ function AddressListsTab({
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} записей</span>
<Button size="sm" disabled={mutationsDisabled} onClick={onAdd}><PlusIcon className="size-4" />Добавить</Button>
<Button data-firewall-add-address size="sm" disabled={mutationsDisabled} onClick={onAdd}><PlusIcon className="size-4" />Добавить</Button>
</div>
<div className="flex flex-col gap-3">
@@ -812,7 +787,7 @@ function AddressListsTab({
</div>
<div className="divide-y divide-border">
{listEntries.map((e) => (
<div key={e.id} className={cn(
<div key={e.id} data-firewall-address-id={e.id} className={cn(
"flex items-center gap-3 px-4 py-2.5",
e.disabled && "opacity-50",
)}>
@@ -865,7 +840,7 @@ const defaultAddrForm = {
}
function AddressListSheet({
open, onClose, initial, family, onSave, saving = false, error,
open, onClose, initial, family, onSave, saving = false, error, returnFocus,
}: {
open: boolean
onClose: () => void
@@ -874,24 +849,28 @@ function AddressListSheet({
onSave: (payload: { list: string; address: string; comment?: string; timeout?: string; disabled?: boolean }) => void
saving?: boolean
error?: string | null
returnFocus?: () => HTMLElement | null
}) {
const isNew = !initial?.id
const [form, setForm] = useState(defaultAddrForm)
const [formContext, setFormContext] = useState({ open: false, initial })
useEffect(() => {
if (!open) return
setForm({
list: initial?.list ?? "",
address: initial?.address ?? "",
comment: initial?.comment ?? "",
timeout: initial?.timeout ?? "",
enabled: !(initial?.disabled ?? false),
})
}, [open, initial])
if (formContext.open !== open || formContext.initial !== initial) {
setFormContext({ open, initial })
if (open) {
setForm({
list: initial?.list ?? "",
address: initial?.address ?? "",
comment: initial?.comment ?? "",
timeout: initial?.timeout ?? "",
enabled: !(initial?.disabled ?? false),
})
}
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v && !saving) onClose() }}>
<SheetContent showCloseButton={!saving} className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-md">
<SheetContent finalFocus={returnFocus} showCloseButton={!saving} className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-md">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<SheetTitle>{isNew ? "Запись address-list" : "Редактировать запись"}</SheetTitle>
<SheetDescription>
@@ -1986,7 +1965,7 @@ function FirewallPageInner() {
const [deleteError, setDeleteError] = useState<string | null>(null)
const [selectedServerId, setSelectedServerId] = useState(serverFromQuery ?? ALL_SERVERS_ID)
const appliedQueryRef = useRef(false)
const [queryApplied, setQueryApplied] = useState(false)
const [chainGroup, setChainGroup] = useState<ChainGroup>("filter")
const [chainFilter, setChainFilter] = useState<ChainFilter>("all")
@@ -2109,14 +2088,10 @@ function FirewallPageInner() {
const displayAddrLists = isLive ? liveAddrLists : mockAddrLists
const displayServers = isLive ? liveServers : mockServers
useEffect(() => {
if (appliedQueryRef.current || !serverFromQuery) return
if (displayServers.length === 0) return
if (displayServers.some((s) => s.id === serverFromQuery)) {
setSelectedServerId(serverFromQuery)
}
appliedQueryRef.current = true
}, [displayServers, serverFromQuery])
if (!queryApplied && serverFromQuery && displayServers.length > 0) {
if (displayServers.some(server => server.id === serverFromQuery)) setSelectedServerId(serverFromQuery)
setQueryApplied(true)
}
const scopedRules = useMemo(() => {
if (selectedServerId === ALL_SERVERS_ID) return displayRules
@@ -2585,7 +2560,7 @@ function FirewallPageInner() {
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
<CodeXmlIcon className="size-4" />Экспорт .rsc
</Button>
<Button size="sm" disabled={firewallWriteBlocked} onClick={openAdd}>
<Button data-firewall-new-rule size="sm" disabled={firewallWriteBlocked} onClick={openAdd}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
@@ -2805,6 +2780,7 @@ function FirewallPageInner() {
<RuleSheet
open={ruleSheetOpen}
onClose={() => { if (!mutationLock.current) setRuleSheetOpen(false) }}
returnFocus={() => (editingRule?.id ? document.querySelector<HTMLButtonElement>(`tr[data-row-id="${CSS.escape(editingRule.id)}"] button[aria-haspopup="menu"]`) : null) ?? document.querySelector<HTMLButtonElement>("[data-firewall-new-rule]")}
initialRule={editingRule}
chainGroup={sheetChainGroup}
family={editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily}
@@ -2816,6 +2792,7 @@ function FirewallPageInner() {
<AddressListSheet
open={addrSheetOpen}
onClose={() => { if (!mutationLock.current) setAddrSheetOpen(false) }}
returnFocus={() => (editingAddr?.id ? document.querySelector<HTMLButtonElement>(`[data-firewall-address-id="${CSS.escape(editingAddr.id)}"] button[aria-haspopup="menu"]`) : null) ?? document.querySelector<HTMLButtonElement>("[data-firewall-add-address]")}
initial={editingAddr}
family={editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily}
onSave={(payload) => { void saveAddr(payload) }}
+2
View File
@@ -89,3 +89,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич
12.3.45: локальный сценарий1000→50 rows/1847 modal nodes; pagination/full draft999/скрытый verdict#999/320px/axe проверены,6 browser/type/grid lint/build прошли. Трассировка999 остаётся15529 page nodes, требует12.3.46; ошибки записи localStorage не проверены.10 manualPagination:true остаются.
12.3.46: trace999/15529→1293 page nodes,50 steps/whole verdict#999/страницы/jump/stop→restart epoch/reset/320px/axe проверены;6+1 browser/type/test lint/build прошли. Полный steps в памяти и O(N²) обновления instant не менялись; общий CPU performance не принят.10 manualPagination:true остаются.
12.3.47: Firewall draft/URL/list filter/focus/lint cleanup завершены,26 main/context4/type/полный ESLint страницы/build прошли.10 manualPagination:true остаются; далее полный OSPF1000 baseline, включая SVG topology.
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-04
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принLine truncated
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принLine truncated
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+8
View File
@@ -31,6 +31,14 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13–12.3.25 приняты локально в описанных границах;12.4.4 context213 и12.4.5 main1110 прошли, 12.3.26–12.3.33 приняты локально; 12.3.34 VXLAN и12.3.35 GRE tunnels приняты; остаются network grids/общая регрессия после delta/внешняя приёмка |
## 12.3.47 — формы, URL и lint Firewall (2026-10-04)
RuleSheet/AddressListSheet корректируют собственный draft при смене контекста до commit дочерних элементов, сохраняя ввод при API ошибке и pending. Sheet не перемонтируется; finalFocus возвращает к исходной кнопке меню строки, fallback — create/add. Address-list фильтр отсутствующего списка сбрасывается до вывода детей и не возвращается при повторном появлении списка. Query server применяется один раз после каталога через guarded state adjustment; начальный useState недостаточен для асинхронного URL-контекста. Удалены дубли unused styles.
6+20 main browser и4/4 context прошли: reopen старого rule/address после несохранённого draft, create с пустым draft, Escape/focus, удаление/возврат фильтра,8 save cases/ошибки API/права/partial/cache/stale, обе большие таблицы/trace и поздние ответы. TypeScript без incremental/ESLint страницы и нового теста/production build прошли. Четыре исходные lint errors/2 warnings устранены без suppression; подход соответствует [React: Adjusting some state when a prop changes](https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes). Сетевые правила не изменены, real RouterOS не проверен.
Следующий блок12.3.48: полный профиль OSPF1000 соседей/1001 SVG узел, таблица и topology вместе; определить точную границу DOM до изменений. Затем ограничение таблицы/графа, optimizer. LocalStorage отказ сценария и большой multi-server sort Firewall остаются отдельными проверками.10 manualPagination:true остаются. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
## 12.3.46 — ограниченная трассировка Firewall (2026-10-04)
Trace показывает50 шагов, общий verdict/counts/номера относятся ко всему расчёту. Предыдущая/следующая страница и переход к правилу результата44px; новая симуляция/reset/preset/scenario начинают с первой страницы. Строки переносятся на320px, исправлен контраст passthrough и пояснений. runEpoch защищает от старого таймера после stop→новый run; unmount инвалидирует запуск.
+9
View File
@@ -569,3 +569,12 @@ Trace показывает50 шагов, общий verdict/counts/номера
Следующий блок12.3.47: Firewall lint cleanup четырёх старых эффектов без потери draft/URL/filter/focus. Затем OSPF таблица+topology/optimizer; ошибки localStorage и большой multi-server sort отдельно.10 manualPagination:true остаются. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
## 12.3.47 — формы, URL и lint Firewall (2026-10-04)
RuleSheet/AddressListSheet корректируют собственный draft при смене контекста до commit дочерних элементов, сохраняя ввод при API ошибке и pending. Sheet не перемонтируется; finalFocus возвращает к исходной кнопке меню строки, fallback — create/add. Address-list фильтр отсутствующего списка сбрасывается до вывода детей и не возвращается при повторном появлении списка. Query server применяется один раз после каталога через guarded state adjustment; начальный useState недостаточен для асинхронного URL-контекста. Удалены дубли unused styles.
6+20 main browser и4/4 context прошли: reopen старого rule/address после несохранённого draft, create с пустым draft, Escape/focus, удаление/возврат фильтра,8 save cases/ошибки API/права/partial/cache/stale, обе большие таблицы/trace и поздние ответы. TypeScript без incremental/ESLint страницы и нового теста/production build прошли. Четыре исходные lint errors/2 warnings устранены без suppression; подход соответствует [React: Adjusting some state when a prop changes](https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes). Сетевые правила не изменены, real RouterOS не проверен.
Следующий блок12.3.48: полный профиль OSPF1000 соседей/1001 SVG узел, таблица и topology вместе; определить точную границу DOM до изменений. Затем ограничение таблицы/графа, optimizer. LocalStorage отказ сценария и большой multi-server sort Firewall остаются отдельными проверками.10 manualPagination:true остаются. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
+45
View File
@@ -0,0 +1,45 @@
import { test, expect } from "@playwright/test"
for (const kind of ["rule", "address"] as const) {
test(`Firewall: новый draft ${kind} при повторном открытии и возврат фокуса`, async ({ page }) => {
const rule = { id: "r1", rosId: "*1", serverId: "1", serverName: "Роутер", family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Исходный комментарий", enabled: true, hits: 1 }
const entry = { id: "a1", rosId: "*A1", serverId: "1", serverName: "Роутер", family: "ip", list: "trusted", address: "192.0.2.10", comment: "Исходный комментарий", disabled: false }
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const p = new URL(r.request().url()).pathname
return r.fulfill({ json: p === "/api/auth/config" ? { required: false } : p === "/api/firewall/all" ? { rules: [rule], addressLists: [entry] } : p === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/firewall?server=1"); await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
if (kind === "address") await page.getByRole("button", { name: /Адр. листы/ }).click()
const action = page.getByRole("button", { name: kind === "rule" ? "Действия: forward accept" : "Действия: 192.0.2.10", exact: true })
const open = async () => { await action.click(); await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click() }
await open()
const dialog = page.getByRole("dialog")
const comment = dialog.locator('[data-slot="field"]').filter({ has: page.getByText("Комментарий", { exact: true }) }).getByRole("textbox")
await expect(comment).toHaveValue("Исходный комментарий")
await comment.fill("Несохранённый draft"); await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0); await expect(action).toBeFocused()
await open(); await expect(comment).toHaveValue("Исходный комментарий")
await dialog.getByRole("button", { name: "Отмена", exact: true }).click(); await expect(dialog).toHaveCount(0)
await page.getByRole("button", { name: kind === "rule" ? "Новое правило" : "Добавить", exact: true }).click()
await expect(comment).toHaveValue("")
})
}
test("Firewall: удалённый list фильтр сбрасывается и не возвращается после refresh", async ({ page }) => {
const entry = { id: "a1", rosId: "*A1", serverId: "1", serverName: "Роутер", family: "ip", list: "trusted", address: "192.0.2.10", comment: "", disabled: false }
let entries = [entry]
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const p = new URL(r.request().url()).pathname
return r.fulfill({ json: p === "/api/auth/config" ? { required: false } : p === "/api/firewall/all" ? { rules: [], addressLists: entries } : p === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }] : {} })
})
await page.goto("/firewall?server=1"); await page.getByRole("button", { name: /Адр. листы/ }).click()
await page.getByRole("button", { name: "trusted", exact: true }).click()
const other = { ...entry, id: "a2", rosId: "*A2", list: "other", address: "192.0.2.20" }
entries = [other]; await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("button", { name: "Действия: 192.0.2.20", exact: true })).toBeVisible()
entries = [entry, other]; await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("button", { name: "Действия: 192.0.2.10", exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "Действия: 192.0.2.20", exact: true })).toBeVisible()
})