fix(ui): блокировать операции Firewall после ошибки чтения

This commit is contained in:
Denozordec committed 2026-10-04 16:29:31 +07:00
1 parent 1e9889f892
commit 2e648dcf1c
6 files changed
+88 -19

No files matched your search

+25 -18
View File
@@ -1936,6 +1936,7 @@ function FirewallPageInner() {
const [dataLoading, setDataLoading] = useState(false) const [dataLoading, setDataLoading] = useState(false)
const [dataError, setDataError] = useState<string | null>(null) const [dataError, setDataError] = useState<string | null>(null)
const [hasLoaded, setHasLoaded] = useState(false) const [hasLoaded, setHasLoaded] = useState(false)
const [readTrusted, setReadTrusted] = useState(false)
const [sliceTimes, setSliceTimes] = useState<FirewallSliceTime[]>([]) const [sliceTimes, setSliceTimes] = useState<FirewallSliceTime[]>([])
const [failures, setFailures] = useState<FirewallFailure[]>([]) const [failures, setFailures] = useState<FirewallFailure[]>([])
const loadEpoch = useRef(0) const loadEpoch = useRef(0)
@@ -1995,8 +1996,10 @@ function FirewallPageInner() {
const receivedAt = new Date().toISOString() const receivedAt = new Date().toISOString()
setSliceTimes(previous => updateFirewallSliceTimes(previous, [...enabledIds], firewall.data.failures, receivedAt)) setSliceTimes(previous => updateFirewallSliceTimes(previous, [...enabledIds], firewall.data.failures, receivedAt))
setHasLoaded(true) setHasLoaded(true)
setReadTrusted(true)
} catch (e) { } catch (e) {
if (epoch !== loadEpoch.current) return if (epoch !== loadEpoch.current) return
setReadTrusted(false)
setDataError(e instanceof Error ? e.message : "Ошибка загрузки") setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
} finally { } finally {
if (epoch === loadEpoch.current) setDataLoading(false) if (epoch === loadEpoch.current) setDataLoading(false)
@@ -2029,6 +2032,7 @@ function FirewallPageInner() {
const restoreRevision = useCallback(async (id: string) => { const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) throw new Error("Выберите устройство в режиме Live") if (!isLive || !historyServerId) throw new Error("Выберите устройство в режиме Live")
if (mutationLock.current) throw new Error("Дождитесь завершения текущей операции Firewall") if (mutationLock.current) throw new Error("Дождитесь завершения текущей операции Firewall")
if (!readTrusted) throw new Error("Обновите актуальные данные Firewall перед восстановлением")
if (failures.some(f => f.serverId === historyServerId)) throw new Error("Обновите полный набор данных устройства перед восстановлением") if (failures.some(f => f.serverId === historyServerId)) throw new Error("Обновите полный набор данных устройства перед восстановлением")
mutationLock.current = true mutationLock.current = true
loadEpoch.current += 1 loadEpoch.current += 1
@@ -2050,7 +2054,7 @@ function FirewallPageInner() {
mutationLock.current = false mutationLock.current = false
setHistoryRestoring(false) setHistoryRestoring(false)
} }
}, [isLive, historyServerId, apiFetch, loadLive, loadRevisions, failures]) }, [isLive, historyServerId, apiFetch, loadLive, loadRevisions, failures, readTrusted])
useEffect(() => { useEffect(() => {
queueMicrotask(() => { queueMicrotask(() => {
@@ -2156,6 +2160,7 @@ function FirewallPageInner() {
const scopedFailures = isLive ? failures.filter(f => (selectedServerId === ALL_SERVERS_ID || f.serverId === selectedServerId) && (!f.family || f.family === ipFamily)) : [] const scopedFailures = isLive ? failures.filter(f => (selectedServerId === ALL_SERVERS_ID || f.serverId === selectedServerId) && (!f.family || f.family === ipFamily)) : []
const firewallIncomplete = scopedFailures.length > 0 const firewallIncomplete = scopedFailures.length > 0
const firewallWriteBlocked = firewallIncomplete || (isLive && !readTrusted)
const scopedSliceTimes = sliceTimes.filter(slice => (selectedServerId === ALL_SERVERS_ID || slice.serverId === selectedServerId) && slice.family === ipFamily) const scopedSliceTimes = sliceTimes.filter(slice => (selectedServerId === ALL_SERVERS_ID || slice.serverId === selectedServerId) && slice.family === ipFamily)
const freshness = firewallTimeRange(scopedSliceTimes) const freshness = firewallTimeRange(scopedSliceTimes)
const totalEnabled = familyRules.filter((r) => r.enabled).length const totalEnabled = familyRules.filter((r) => r.enabled).length
@@ -2171,7 +2176,7 @@ function FirewallPageInner() {
async function toggleRule(id: string) { async function toggleRule(id: string) {
const r = displayRules.find((x) => x.id === id) const r = displayRules.find((x) => x.id === id)
if (!r || mutationLock.current || busy || historyRestoring || firewallIncomplete) return if (!r || mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
if (isLive) { if (isLive) {
const key = ruleKey(r) const key = ruleKey(r)
if (!key) return if (!key) return
@@ -2202,13 +2207,13 @@ function FirewallPageInner() {
} }
function requestDelete(target: NonNullable<typeof pendingDelete>) { function requestDelete(target: NonNullable<typeof pendingDelete>) {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
setDeleteError(null) setDeleteError(null)
setPendingDelete(target) setPendingDelete(target)
} }
async function confirmDelete() { async function confirmDelete() {
if (!pendingDelete || mutationLock.current || busy || historyRestoring || firewallIncomplete) return if (!pendingDelete || mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
const target = pendingDelete const target = pendingDelete
mutationLock.current = true mutationLock.current = true
loadEpoch.current += 1 loadEpoch.current += 1
@@ -2248,7 +2253,7 @@ function FirewallPageInner() {
}, [filteredRules]) }, [filteredRules])
async function reorderRules(activeId: string, overId: string) { async function reorderRules(activeId: string, overId: string) {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
const source = isLive ? liveRules : mockRules const source = isLive ? liveRules : mockRules
const next = reorderWithinGroup(source, activeId, overId) const next = reorderWithinGroup(source, activeId, overId)
if (!next) { if (!next) {
@@ -2297,7 +2302,7 @@ function FirewallPageInner() {
} }
function openAdd() { function openAdd() {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
setFormError(null) setFormError(null)
if (!requireWriteServer()) return if (!requireWriteServer()) return
const table = tableOfGroup(chainGroup) const table = tableOfGroup(chainGroup)
@@ -2309,14 +2314,14 @@ function FirewallPageInner() {
} }
function openEdit(r: FirewallRule) { function openEdit(r: FirewallRule) {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
setFormError(null) setFormError(null)
setEditingRule(r) setEditingRule(r)
setRuleSheetOpen(true) setRuleSheetOpen(true)
} }
async function saveRule(payload: RuleWritePayload) { async function saveRule(payload: RuleWritePayload) {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
const table = (editingRule?.table ?? tableOfGroup(chainGroup) ?? "filter") as FirewallTable const table = (editingRule?.table ?? tableOfGroup(chainGroup) ?? "filter") as FirewallTable
const family = editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily const family = editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily
const serverId = editingRule?.serverId ?? requireWriteServer() const serverId = editingRule?.serverId ?? requireWriteServer()
@@ -2387,7 +2392,7 @@ function FirewallPageInner() {
} }
function openAddAddr(list?: string) { function openAddAddr(list?: string) {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
setFormError(null) setFormError(null)
if (!requireWriteServer()) return if (!requireWriteServer()) return
setEditingAddr(list ? { list } : null) setEditingAddr(list ? { list } : null)
@@ -2395,14 +2400,14 @@ function FirewallPageInner() {
} }
function openEditAddr(e: AddressListEntry) { function openEditAddr(e: AddressListEntry) {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
setFormError(null) setFormError(null)
setEditingAddr(e) setEditingAddr(e)
setAddrSheetOpen(true) setAddrSheetOpen(true)
} }
async function toggleAddr(e: AddressListEntry) { async function toggleAddr(e: AddressListEntry) {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
if (isLive) { if (isLive) {
const key = addressKey(e) const key = addressKey(e)
if (!key) return if (!key) return
@@ -2439,7 +2444,7 @@ function FirewallPageInner() {
timeout?: string timeout?: string
disabled?: boolean disabled?: boolean
}) { }) {
if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
const family = editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily const family = editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily
const serverId = editingAddr?.serverId ?? requireWriteServer() const serverId = editingAddr?.serverId ?? requireWriteServer()
if (!serverId) return if (!serverId) return
@@ -2540,7 +2545,7 @@ function FirewallPageInner() {
setHistoryOpen(true) setHistoryOpen(true)
void loadRevisions() void loadRevisions()
}} }}
disabled={!isLive || !historyServerId || dataLoading || busy || togglePending || deletePending || movePending || historyRestoring || failures.some(f => f.serverId === historyServerId)} disabled={!isLive || !historyServerId || dataLoading || busy || togglePending || deletePending || movePending || historyRestoring || !readTrusted || failures.some(f => f.serverId === historyServerId)}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"} title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
> >
<HistoryIcon className="size-4" /> <HistoryIcon className="size-4" />
@@ -2549,7 +2554,7 @@ function FirewallPageInner() {
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}> <Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
<CodeXmlIcon className="size-4" />Экспорт .rsc <CodeXmlIcon className="size-4" />Экспорт .rsc
</Button> </Button>
<Button size="sm" disabled={firewallIncomplete} onClick={openAdd}> <Button size="sm" disabled={firewallWriteBlocked} onClick={openAdd}>
<PlusIcon className="size-4" />Новое правило <PlusIcon className="size-4" />Новое правило
</Button> </Button>
</> </>
@@ -2565,6 +2570,8 @@ function FirewallPageInner() {
</div> </div>
)} )}
{isLive && hasLoaded && !readTrusted && <p role="status" className="text-sm text-muted-foreground">Изменения Firewall заблокированы до успешного чтения актуальных данных. Последний список сохранён.</p>}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<SegmentedControl <SegmentedControl
value={ipFamily} value={ipFamily}
@@ -2666,7 +2673,7 @@ function FirewallPageInner() {
) : chainGroup === "address-lists" ? ( ) : chainGroup === "address-lists" ? (
<AddressListsTab <AddressListsTab
entries={familyAddrLists} entries={familyAddrLists}
mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring || firewallIncomplete} mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring || firewallWriteBlocked}
onAdd={() => openAddAddr()} onAdd={() => openAddAddr()}
onAddToList={(list) => openAddAddr(list)} onAddToList={(list) => openAddAddr(list)}
onToggle={(e) => { void toggleAddr(e) }} onToggle={(e) => { void toggleAddr(e) }}
@@ -2708,11 +2715,11 @@ function FirewallPageInner() {
<FirewallRulesDataGrid <FirewallRulesDataGrid
rules={filteredRules} rules={filteredRules}
mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring || firewallIncomplete} mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring || firewallWriteBlocked}
onToggle={(id) => { void toggleRule(id) }} onToggle={(id) => { void toggleRule(id) }}
onEdit={openEdit} onEdit={openEdit}
onDelete={(item) => requestDelete({ kind: "rule", item })} onDelete={(item) => requestDelete({ kind: "rule", item })}
onReorder={canReorder && !togglePending && !deletePending && !movePending && !busy && !historyRestoring && !firewallIncomplete ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined} onReorder={canReorder && !togglePending && !deletePending && !movePending && !busy && !historyRestoring && !firewallWriteBlocked ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
showServer={showServerCol} showServer={showServerCol}
/> />
</DataPageCard> </DataPageCard>
@@ -2807,7 +2814,7 @@ function FirewallPageInner() {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={deletePending}>Отмена</AlertDialogCancel> <AlertDialogCancel disabled={deletePending}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={deletePending || togglePending || busy || historyRestoring || firewallIncomplete} onClick={e => { e.preventDefault(); void confirmDelete() }}>Удалить</AlertDialogAction> <AlertDialogAction variant="destructive" disabled={deletePending || togglePending || busy || historyRestoring || firewallWriteBlocked} onClick={e => { e.preventDefault(); void confirmDelete() }}>Удалить</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
+2
View File
@@ -84,3 +84,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич
12.3.42: Filters1000/120894 JSON bytes →50 rows/2266 nodes/1880ms dev ready. Global order/граница страниц/полный PUT/403/поиск/сортировка/refresh/cache/clamp/320px/axe проверены;10+1 browser/type/lint/build прошли.12 manualPagination:true остаются. 12.3.42: Filters1000/120894 JSON bytes →50 rows/2266 nodes/1880ms dev ready. Global order/граница страниц/полный PUT/403/поиск/сортировка/refresh/cache/clamp/320px/axe проверены;10+1 browser/type/lint/build прошли.12 manualPagination:true остаются.
12.3.43: Firewall1000/253248 JSON bytes →50 rows/3054 nodes/2082ms dev ready. Cross-page кнопки/точный RouterOS destination/DnD page2/403/search/cache/clamp/320px/axe проверены;35+1 main/context4/type/lint/build прошли. Stale operations после полного GET failure требуют12.3.44; большой multi-server sort отдельно не проверен.11 manualPagination:true остаются. 12.3.43: Firewall1000/253248 JSON bytes →50 rows/3054 nodes/2082ms dev ready. Cross-page кнопки/точный RouterOS destination/DnD page2/403/search/cache/clamp/320px/axe проверены;35+1 main/context4/type/lint/build прошли. Stale operations после полного GET failure требуют12.3.44; большой multi-server sort отдельно не проверен.11 manualPagination:true остаются.
12.3.44: stale операции Firewall заблокированы до успешного read/после retry, ограничения12.3.43 по полному GET failure исправлены.29+2 main/context4/type/build прошли;4 исходных ESLint errors страницы подтверждены сравнением с HEAD.11 manualPagination:true остаются.
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-04 ## Актуальная точка продолжения — 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). Реальные 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 | | 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 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;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.44 — блокировка устаревших операций Firewall (2026-10-04)
readTrusted отделён от текста dataError: после HTTP/DTO failure кэш сохраняется, но toggle/edit/delete/create/move/address-list/history restore заблокированы. Очистка ошибки при retry не разблокирует действия до корректного ответа. Существующие partial failures дополнительно сохраняют scoped блокировку; поздний read после мутации не сбрасывает подтверждение. Статус объясняет блокировку и сохранённый список, операции RouterOS не изменены.
29/29 main browser (data/partial/save/delete/toggle/move/history/large),2/2 новых stale cases и4/4 context прошли. Проверены503/некорректный DTO, неизменная строка, отключённые switch/menu/create/history, pending retry без writes, восстановление после успешного GET. TypeScript без incremental, ESLint нового теста и production build прошли. ESLint страницы имеет4 исходных set-state-in-effect errors (506/754/883/2081 в HEAD1e9889f) и2 unused warnings; сравнение ESLint API с HEAD подтвердило тот же набор в текущем файле (последняя строка2085). Это не полный lint pass; исправление прежних эффектов отдельно.
Следующий блок12.3.45: большой редактор локального сценария Firewall — pagination/глобальный порядок/сохранение всего draft/симуляция/320px. Затем OSPF таблица+topology/optimizer.11 manualPagination:true остаются. Общая main/context после delta, lint cleanup Firewall и внешняя12.5 отдельно. Коммит локальный без push.
## 12.3.43 — большая таблица Firewall и порядок (2026-10-04) ## 12.3.43 — большая таблица Firewall и порядок (2026-10-04)
ReUI pagination50/clamp/ScrollArea, закреплённые номер и chain. DnD получает ID текущей страницы, кнопки44px up/down используют соседей полного отфильтрованного списка внутри server/family/table. Callback прежний: RouterOS destination рассчитывается по полному источнику, включая скрытые строки. DnD мышью/клавиатурой сохранён; action menu44px видим без hover. Переключателям добавлены доступные имена с rosId. ReUI pagination50/clamp/ScrollArea, закреплённые номер и chain. DnD получает ID текущей страницы, кнопки44px up/down используют соседей полного отфильтрованного списка внутри server/family/table. Callback прежний: RouterOS destination рассчитывается по полному источнику, включая скрытые строки. DnD мышью/клавиатурой сохранён; action menu44px видим без hover. Переключателям добавлены доступные имена с rosId.
+9
View File
@@ -542,3 +542,12 @@ ReUI pagination50/clamp/ScrollArea, закреплённые номер и chain
Обнаружено прежнее ограничение: dataError после полного GET failure сохраняет кэш, но не блокирует действия, в отличие от partial failures. Следующий блок12.3.44: явная блокировка stale операций/повторного чтения/восстановления; затем большой редактор сценария и OSPF/optimizer.11 manualPagination:true specialized grids остаются. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push. Обнаружено прежнее ограничение: dataError после полного GET failure сохраняет кэш, но не блокирует действия, в отличие от partial failures. Следующий блок12.3.44: явная блокировка stale операций/повторного чтения/восстановления; затем большой редактор сценария и OSPF/optimizer.11 manualPagination:true specialized grids остаются. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
## 12.3.44 — блокировка устаревших операций Firewall (2026-10-04)
readTrusted отделён от текста dataError: после HTTP/DTO failure кэш сохраняется, но toggle/edit/delete/create/move/address-list/history restore заблокированы. Очистка ошибки при retry не разблокирует действия до корректного ответа. Существующие partial failures дополнительно сохраняют scoped блокировку; поздний read после мутации не сбрасывает подтверждение. Статус объясняет блокировку и сохранённый список, операции RouterOS не изменены.
29/29 main browser (data/partial/save/delete/toggle/move/history/large),2/2 новых stale cases и4/4 context прошли. Проверены503/некорректный DTO, неизменная строка, отключённые switch/menu/create/history, pending retry без writes, восстановление после успешного GET. TypeScript без incremental, ESLint нового теста и production build прошли. ESLint страницы имеет4 исходных set-state-in-effect errors (506/754/883/2081 в HEAD1e9889f) и2 unused warnings; сравнение ESLint API с HEAD подтвердило тот же набор в текущем файле (последняя строка2085). Это не полный lint pass; исправление прежних эффектов отдельно.
Следующий блок12.3.45: большой редактор локального сценария Firewall — pagination/глобальный порядок/сохранение всего draft/симуляция/320px. Затем OSPF таблица+topology/optimizer.11 manualPagination:true остаются. Общая main/context после delta, lint cleanup Firewall и внешняя12.5 отдельно. Коммит локальный без push.
+43
View File
@@ -0,0 +1,43 @@
import { test, expect } from "@playwright/test"
for (const failure of ["http", "invalid"] as const) {
test(`Firewall: ${failure} блокирует кэш до завершения успешного повтора`, 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 }
let failed = false, gated = false, writes = 0, started = false
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", async r => {
const p = new URL(r.request().url()).pathname
if (r.request().method() !== "GET") { writes++; return r.fulfill({ json: { ok: true } }) }
if (p === "/api/firewall/all") {
if (gated) { started = true; await gate }
if (failed) return r.fulfill(failure === "http" ? { status: 503, json: { error: "Чтение недоступно stale" } } : { json: { rules: "invalid", addressLists: [] } })
return r.fulfill({ json: { rules: [rule], addressLists: [] } })
}
return r.fulfill({ json: p === "/api/auth/config" ? { required: false } : 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")
const rows = page.locator("tr[data-row-id]")
const toggle = rows.getByRole("switch", { name: "Включено правило *1", exact: true })
const history = page.getByRole("button", { name: "История", exact: true })
await expect(toggle).toBeEnabled(); await expect(history).toBeEnabled()
failed = true
await page.getByRole("button", { name: "Обновить", exact: true }).click()
const status = page.getByRole("status").filter({ hasText: "Изменения Firewall заблокированы" })
await expect(status).toBeVisible(); await expect(rows).toHaveCount(1)
await expect(toggle).toBeDisabled(); await expect(history).toBeDisabled()
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeDisabled()
await rows.getByRole("button", { name: "Действия: forward accept", exact: true }).click()
for (const name of ["Редактировать", "Отключить", "Удалить правило"]) await expect(page.getByRole("menuitem", { name, exact: true })).toBeDisabled()
await page.keyboard.press("Escape")
failed = false; gated = true
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect.poll(() => started).toBe(true)
await expect(status).toBeVisible(); await expect(toggle).toBeDisabled(); await expect(history).toBeDisabled()
expect(writes).toBe(0)
release!()
await expect(status).toHaveCount(0); await expect(toggle).toBeEnabled(); await expect(history).toBeEnabled()
await expect(rows).toHaveCount(1); expect(writes).toBe(0)
})
}