diff --git a/app/(main)/firewall/page.tsx b/app/(main)/firewall/page.tsx
index 202a672a..eb7aca82 100644
--- a/app/(main)/firewall/page.tsx
+++ b/app/(main)/firewall/page.tsx
@@ -21,7 +21,7 @@ import {
type Server,
} from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
-import { firewallResponseSchema, firewallServersSchema, type FirewallBackendServer } from "@/lib/firewall-response"
+import { firewallResponseSchema, firewallServersSchema, mergeFirewallRows, type FirewallFailure, type FirewallBackendServer } from "@/lib/firewall-response"
import { requestJson } from "@/shared/api/http-client"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
@@ -792,7 +792,7 @@ function AddressListsTab({
))}
{filtered.length} записей
-
+
@@ -805,7 +805,7 @@ function AddressListsTab({
{listName}
({listEntries.length})
-
} />
- onEdit(e)}>Редактировать
+ onEdit(e)}>Редактировать
onToggle(e)}>
{e.disabled ? "Включить" : "Отключить"}
@@ -1930,6 +1930,7 @@ function FirewallPageInner() {
const [dataLoading, setDataLoading] = useState(false)
const [dataError, setDataError] = useState(null)
const [hasLoaded, setHasLoaded] = useState(false)
+ const [failures, setFailures] = useState([])
const loadEpoch = useRef(0)
useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive])
const [busy, setBusy] = useState(false)
@@ -1980,8 +1981,10 @@ function FirewallPageInner() {
const firewall = firewallResponseSchema.safeParse(fw)
if (!servers.success || !firewall.success) throw new Error("Сервер вернул некорректные данные Firewall")
setLiveServers(servers.data.map(mapBackendToServer))
- setLiveRules(firewall.data.rules)
- setLiveAddrLists(firewall.data.addressLists)
+ const enabledIds = new Set(servers.data.filter(server => server.enabled).map(server => String(server.id)))
+ setLiveRules(previous => mergeFirewallRows(previous, firewall.data.rules, firewall.data.failures, enabledIds))
+ setLiveAddrLists(previous => mergeFirewallRows(previous, firewall.data.addressLists, firewall.data.failures, enabledIds, true))
+ setFailures(firewall.data.failures)
setHasLoaded(true)
} catch (e) {
if (epoch !== loadEpoch.current) return
@@ -2017,6 +2020,7 @@ function FirewallPageInner() {
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) throw new Error("Выберите устройство в режиме Live")
if (mutationLock.current) throw new Error("Дождитесь завершения текущей операции Firewall")
+ if (failures.some(f => f.serverId === historyServerId)) throw new Error("Обновите полный набор данных устройства перед восстановлением")
mutationLock.current = true
loadEpoch.current += 1
setDataLoading(false)
@@ -2036,7 +2040,7 @@ function FirewallPageInner() {
mutationLock.current = false
setHistoryRestoring(false)
}
- }, [isLive, historyServerId, apiFetch, loadLive, loadRevisions])
+ }, [isLive, historyServerId, apiFetch, loadLive, loadRevisions, failures])
useEffect(() => {
queueMicrotask(() => {
@@ -2044,6 +2048,7 @@ function FirewallPageInner() {
setLiveRules([])
setLiveAddrLists([])
setHasLoaded(false)
+ setFailures([])
setDataError(null)
setDataLoading(false)
if (isLive) void loadLive()
@@ -2138,6 +2143,8 @@ function FirewallPageInner() {
return counts
}, [groupRules])
+ const scopedFailures = isLive ? failures.filter(f => (selectedServerId === ALL_SERVERS_ID || f.serverId === selectedServerId) && (!f.family || f.family === ipFamily)) : []
+ const firewallIncomplete = scopedFailures.length > 0
const totalEnabled = familyRules.filter((r) => r.enabled).length
const totalHits = familyRules.reduce((s, r) => s + r.hits, 0)
const dropRules = familyRules.filter((r) => r.action === "drop" || r.action === "reject").length
@@ -2151,7 +2158,7 @@ function FirewallPageInner() {
async function toggleRule(id: string) {
const r = displayRules.find((x) => x.id === id)
- if (!r || mutationLock.current || busy || historyRestoring) return
+ if (!r || mutationLock.current || busy || historyRestoring || firewallIncomplete) return
if (isLive) {
const key = ruleKey(r)
if (!key) return
@@ -2181,13 +2188,13 @@ function FirewallPageInner() {
}
function requestDelete(target: NonNullable) {
- if (mutationLock.current || busy || historyRestoring) return
+ if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setDeleteError(null)
setPendingDelete(target)
}
async function confirmDelete() {
- if (!pendingDelete || mutationLock.current || busy || historyRestoring) return
+ if (!pendingDelete || mutationLock.current || busy || historyRestoring || firewallIncomplete) return
const target = pendingDelete
mutationLock.current = true
loadEpoch.current += 1
@@ -2225,7 +2232,7 @@ function FirewallPageInner() {
}, [filteredRules])
async function reorderRules(activeId: string, overId: string) {
- if (mutationLock.current || busy || historyRestoring) return
+ if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
const source = isLive ? liveRules : mockRules
const next = reorderWithinGroup(source, activeId, overId)
if (!next) {
@@ -2273,7 +2280,7 @@ function FirewallPageInner() {
}
function openAdd() {
- if (mutationLock.current || busy || historyRestoring) return
+ if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setFormError(null)
if (!requireWriteServer()) return
const table = tableOfGroup(chainGroup)
@@ -2285,14 +2292,14 @@ function FirewallPageInner() {
}
function openEdit(r: FirewallRule) {
- if (mutationLock.current || busy || historyRestoring) return
+ if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setFormError(null)
setEditingRule(r)
setRuleSheetOpen(true)
}
async function saveRule(payload: RuleWritePayload) {
- if (mutationLock.current || busy || historyRestoring) return
+ if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
const table = (editingRule?.table ?? tableOfGroup(chainGroup) ?? "filter") as FirewallTable
const family = editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily
const serverId = editingRule?.serverId ?? requireWriteServer()
@@ -2361,7 +2368,7 @@ function FirewallPageInner() {
}
function openAddAddr(list?: string) {
- if (mutationLock.current || busy || historyRestoring) return
+ if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setFormError(null)
if (!requireWriteServer()) return
setEditingAddr(list ? { list } : null)
@@ -2369,14 +2376,14 @@ function FirewallPageInner() {
}
function openEditAddr(e: AddressListEntry) {
- if (mutationLock.current || busy || historyRestoring) return
+ if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
setFormError(null)
setEditingAddr(e)
setAddrSheetOpen(true)
}
async function toggleAddr(e: AddressListEntry) {
- if (mutationLock.current || busy || historyRestoring) return
+ if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
if (isLive) {
const key = addressKey(e)
if (!key) return
@@ -2412,7 +2419,7 @@ function FirewallPageInner() {
timeout?: string
disabled?: boolean
}) {
- if (mutationLock.current || busy || historyRestoring) return
+ if (mutationLock.current || busy || historyRestoring || firewallIncomplete) return
const family = editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily
const serverId = editingAddr?.serverId ?? requireWriteServer()
if (!serverId) return
@@ -2511,7 +2518,7 @@ function FirewallPageInner() {
setHistoryOpen(true)
void loadRevisions()
}}
- disabled={!isLive || !historyServerId || dataLoading || busy || togglePending || deletePending || movePending || historyRestoring}
+ disabled={!isLive || !historyServerId || dataLoading || busy || togglePending || deletePending || movePending || historyRestoring || failures.some(f => f.serverId === historyServerId)}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
>
@@ -2520,7 +2527,7 @@ function FirewallPageInner() {
setExportOpen(true)}>
Экспорт .rsc
-
+
Новое правило
>
@@ -2550,6 +2557,11 @@ function FirewallPageInner() {
+ {firewallIncomplete &&
+
Не все срезы Firewall получены
+
Сохранены последние данные недоступных срезов; их состояние неизвестно. Обновите данные перед изменениями.
+ {scopedFailures.map((failure, index) =>
{[failure.serverName || failure.serverId, failure.family, failure.table, failure.error].filter(Boolean).join(" · ")}
)}
+
}
{moveStatus && {moveStatus}
}
{moveError && {moveError}
}
{toggleStatus && {toggleStatus}
}
@@ -2560,28 +2572,28 @@ function FirewallPageInner() {
{
id: "rules",
label: "Всего правил",
- value: isLive && !hasLoaded ? "—" : familyRules.length,
+ value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : familyRules.length,
icon: ,
iconClassName: "text-muted-foreground",
},
{
id: "enabled",
label: "Активных",
- value: isLive && !hasLoaded ? "—" : totalEnabled,
+ value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : totalEnabled,
icon: ,
iconClassName: "text-success",
},
{
id: "drop",
label: "Блокирующих",
- value: isLive && !hasLoaded ? "—" : dropRules,
+ value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : dropRules,
icon: ,
iconClassName: "text-destructive",
},
{
id: "hits",
label: "Срабатываний",
- value: isLive && !hasLoaded ? "—" : fmtHits(totalHits),
+ value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : fmtHits(totalHits),
icon: ,
iconClassName: "text-info",
},
@@ -2612,12 +2624,12 @@ function FirewallPageInner() {
))}
- {isLive && !hasLoaded ? (
+ {isLive && (!hasLoaded || (firewallIncomplete && (chainGroup === "address-lists" ? familyAddrLists.length === 0 : filteredRules.length === 0))) ? (
{dataLoading ? "Загрузка данных Firewall…" : "Данные Firewall пока недоступны. Повторите загрузку."}
) : chainGroup === "address-lists" ? (
openAddAddr()}
onAddToList={(list) => openAddAddr(list)}
onToggle={(e) => { void toggleAddr(e) }}
@@ -2659,11 +2671,11 @@ function FirewallPageInner() {
{ void toggleRule(id) }}
onEdit={openEdit}
onDelete={(item) => requestDelete({ kind: "rule", item })}
- onReorder={canReorder && !togglePending && !deletePending && !movePending && !busy && !historyRestoring ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
+ onReorder={canReorder && !togglePending && !deletePending && !movePending && !busy && !historyRestoring && !firewallIncomplete ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
showServer={showServerCol}
/>
@@ -2758,7 +2770,7 @@ function FirewallPageInner() {
Отмена
- { e.preventDefault(); void confirmDelete() }}>Удалить
+ { e.preventDefault(); void confirmDelete() }}>Удалить
diff --git a/backend/package.json b/backend/package.json
index c338c410..1e9f01c9 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -22,7 +22,7 @@
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
"test:backups": "tsx src/services/s3-backup-client.test.ts",
- "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts",
+ "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync && npm run test:ros-file",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
},
diff --git a/backend/src/services/firewall-live.ts b/backend/src/services/firewall-live.ts
index 2c9c2bf5..a909ec84 100644
--- a/backend/src/services/firewall-live.ts
+++ b/backend/src/services/firewall-live.ts
@@ -1,3 +1,4 @@
+import { pollFirewallChunks, type FirewallChunkFailure } from "./firewall-poll.js"
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
@@ -150,15 +151,6 @@ export function mapAddressList(
}
}
-async function safeGet(fn: () => Promise, fallback: T[] = []): Promise {
- try {
- const rows = await fn()
- return Array.isArray(rows) ? rows : fallback
- } catch {
- return fallback
- }
-}
-
function rosYes(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
@@ -210,28 +202,17 @@ export function mapFirewallSnapshotList(
}
}
-export async function fetchFirewallState(server: ServerRow): Promise<{
+export async function fetchFirewallState(server: ServerRow, allowPartial = false): Promise<{
rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[]
liveRules: FirewallLiveRule[]
liveLists: FirewallLiveList[]
snapshot: FirewallSnapshot
+ failures: FirewallChunkFailure[]
}> {
const client = MikrotikClient.fromServer(server)
- const ruleJobs = FAMILIES.flatMap((family) =>
- TABLES.map(async (table) => {
- const raw = await safeGet(() => client.getFirewallRules(family, table))
- return { family, table, raw }
- }),
- )
- const listJobs = FAMILIES.map(async (family) => {
- const raw = await safeGet(() => client.getFirewallAddressList(family))
- return { family, raw }
- })
- const [ruleChunks, listChunks] = await Promise.all([
- Promise.all(ruleJobs),
- Promise.all(listJobs),
- ])
+ const { ruleChunks, listChunks, failures } = await pollFirewallChunks(client)
+ if (!allowPartial && failures.length > 0) throw new Error("Не удалось полностью прочитать Firewall: " + failures.map(f => f.family + "/" + f.table + ": " + f.error).join("; "))
const rules: FirewallRuleDto[] = []
const liveRules: FirewallLiveRule[] = []
@@ -256,6 +237,7 @@ export async function fetchFirewallState(server: ServerRow): Promise<{
addressLists,
liveRules,
liveLists,
+ failures,
snapshot: canonicalFirewallSnapshot({
rules: liveRules.filter((r) => !r.dynamic),
addressLists: liveLists.filter((e) => !e.dynamic),
@@ -279,28 +261,22 @@ export async function captureFirewallSnapshot(server: ServerRow): Promise & { serverId: string; serverName: string; error: string }>
}> {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
- const perServer = await Promise.all(
- allServers.map(async (server) => {
- try {
- const state = await fetchFirewallState(server)
- await captureAndAppendRevision({
- serverId: server.id,
- section: "firewall",
- source: "observed",
- capture: async () => state.snapshot,
- })
- return { rules: state.rules, addressLists: state.addressLists }
- } catch {
- return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] }
+ const perServer = await Promise.all(allServers.map(async server => {
+ const context = { serverId: String(server.id), serverName: server.name || server.host }
+ try {
+ const state = await fetchFirewallState(server, true)
+ if (state.failures.length === 0) {
+ await captureAndAppendRevision({ serverId: server.id, section: "firewall", source: "observed", capture: async () => state.snapshot })
}
- }),
- )
- return {
- rules: perServer.flatMap((r) => r.rules),
- addressLists: perServer.flatMap((r) => r.addressLists),
- }
+ return { rules: state.rules, addressLists: state.addressLists, failures: state.failures.map(f => ({ ...context, ...f })) }
+ } catch (e) {
+ return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[], failures: [{ ...context, error: e instanceof Error ? e.message : "Ошибка опроса Firewall" }] }
+ }
+ }))
+ return { rules: perServer.flatMap(r => r.rules), addressLists: perServer.flatMap(r => r.addressLists), failures: perServer.flatMap(r => r.failures) }
}
export { firewallRestPath, FAMILIES, TABLES }
diff --git a/backend/src/services/firewall-poll.test.ts b/backend/src/services/firewall-poll.test.ts
new file mode 100644
index 00000000..d3ba9524
--- /dev/null
+++ b/backend/src/services/firewall-poll.test.ts
@@ -0,0 +1,37 @@
+import assert from "node:assert/strict"
+import { pollFirewallChunks } from "./firewall-poll.js"
+import { fetchFirewallState, captureFirewallSnapshot } from "./firewall-live.js"
+import { MikrotikClient } from "./mikrotik.js"
+
+const client = {
+ getFirewallRules: async (family: string, table: string) => {
+ if (family === "ip6" && table === "nat") throw new Error("slice unavailable")
+ return [{ ".id": "*1", chain: "forward", action: "accept", packets: "1" }]
+ },
+ getFirewallAddressList: async () => [],
+}
+const partial = await pollFirewallChunks(client)
+assert.equal(partial.ruleChunks.length, 8)
+assert.equal(partial.listChunks.length, 2)
+assert.deepEqual(partial.failures, [{ family: "ip6", table: "nat", error: "slice unavailable" }])
+assert.equal(partial.ruleChunks.filter(chunk => chunk.raw.length === 1).length, 7)
+const empty = await pollFirewallChunks({ getFirewallRules: async () => [], getFirewallAddressList: async () => [] })
+assert.deepEqual(empty.failures, [])
+assert.ok(empty.ruleChunks.every(chunk => chunk.raw.length === 0))
+const failed = await pollFirewallChunks({ getFirewallRules: async () => { throw new Error("failed") }, getFirewallAddressList: async () => { throw new Error("failed") } })
+assert.equal(failed.failures.length, 10)
+const malformed = await pollFirewallChunks({ getFirewallRules: async () => null as never, getFirewallAddressList: async () => [] })
+assert.equal(malformed.failures.length, 8)
+assert.ok(malformed.failures.every(failure => failure.error.includes("некорректный список")))
+
+const fromServer = MikrotikClient.fromServer
+try {
+ MikrotikClient.fromServer = () => client as unknown as ReturnType
+ const server = { id: 1, name: "Router", host: "192.0.2.1" } as Parameters[0]
+ await assert.rejects(fetchFirewallState(server), /Не удалось полностью прочитать Firewall/)
+ await assert.rejects(captureFirewallSnapshot(server), /Не удалось полностью прочитать Firewall/)
+ const state = await fetchFirewallState(server, true)
+ assert.equal(state.rules.length, 7)
+ assert.deepEqual(state.failures, partial.failures)
+} finally { MikrotikClient.fromServer = fromServer }
+console.log("Firewall polling: mixed/empty/all-failed/malformed and strict snapshot rejection passed")
diff --git a/backend/src/services/firewall-poll.ts b/backend/src/services/firewall-poll.ts
new file mode 100644
index 00000000..3d8acc38
--- /dev/null
+++ b/backend/src/services/firewall-poll.ts
@@ -0,0 +1,26 @@
+import type { FirewallFamily, FirewallTable, RosFirewallFilter, RosFirewallAddressList } from "../types/server.js"
+
+export type FirewallChunkFailure = { family: FirewallFamily; table: FirewallTable | "address-list"; error: string }
+export async function pollFirewallChunks(client: {
+ getFirewallRules: (family: FirewallFamily, table: FirewallTable) => Promise
+ getFirewallAddressList: (family: FirewallFamily) => Promise
+}) {
+ const failures: FirewallChunkFailure[] = []
+ async function read(family: FirewallFamily, table: FirewallChunkFailure["table"], fetch: () => Promise): Promise {
+ try {
+ const rows = await fetch()
+ if (!Array.isArray(rows)) throw new Error("RouterOS вернул некорректный список")
+ return rows
+ } catch (e) {
+ failures.push({ family, table, error: e instanceof Error ? e.message : "Ошибка опроса Firewall" })
+ return []
+ }
+ }
+ const families: FirewallFamily[] = ["ip", "ip6"]
+ const tables: FirewallTable[] = ["filter", "nat", "mangle", "raw"]
+ const [ruleChunks, listChunks] = await Promise.all([
+ Promise.all(families.flatMap(family => tables.map(async table => ({ family, table, raw: await read(family, table, () => client.getFirewallRules(family, table)) })))),
+ Promise.all(families.map(async family => ({ family, raw: await read(family, "address-list", () => client.getFirewallAddressList(family)) }))),
+ ])
+ return { ruleChunks, listChunks, failures }
+}
diff --git a/components/data-grids/firewall-rules-data-grid.tsx b/components/data-grids/firewall-rules-data-grid.tsx
index 7cd55555..498e7d4d 100644
--- a/components/data-grids/firewall-rules-data-grid.tsx
+++ b/components/data-grids/firewall-rules-data-grid.tsx
@@ -290,7 +290,7 @@ function FirewallRulesDataGrid({
}
/>
- onEdit(r)}>
+ onEdit(r)}>
Редактировать
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index 24878512..4aacddb9 100644
--- a/docs/ux-ui-plan.md
+++ b/docs/ux-ui-plan.md
@@ -80,7 +80,7 @@ flowchart LR
## Текущая точка продолжения
-По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.31 — IPsec/GRE/VXLAN и переключение Firewall); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
+По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.32 — IPsec/GRE/VXLAN и переключение Firewall); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.md).
@@ -88,7 +88,7 @@ flowchart LR
Ближайший оставшийся объём:
-1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. Далее — частичные сбои опроса Firewall на backend и сохранение данных по устройству, затем дополнительные проверки контекста и Filters; ограничения счётчика, свежести и записи VXLAN остаются открытыми; загрузка данных IPsec и общая приёмка остаются открытыми.
+1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. В 9.17 подтверждены ответы удаления четырёх типов объектов IPsec и исправлен диалог повторов. В 9.18 исправлено подтверждение сохранения peer IPsec и поведение формы. В 9.19 исправлено подтверждение удаления GRE и сохранение диалога при ошибке. В 9.20 исправлено подтверждение включения/выключения GRE и защита повторов. В 9.21 исправлены подтверждение создания/сохранения GRE и защита формы. В 9.22 исправлены контейнер ответа, первоначальные KPI и сохранение данных при ошибке VXLAN. В 9.23 backend сообщает частичные сбои VXLAN, UI сохраняет данные неответивших устройств и показывает неизвестное состояние. В 9.24 добавлены схемы payload и тесты гонок VXLAN. В 9.25 подтверждены включение/отключение правил Firewall и защита повторов. В 9.26 подтверждены включение/отключение address-list с общим замком переключений. В 9.27 удаление правил/address-list перенесено в диалог с подтверждением API и защитой повторов. В 9.28 создание/редактирование правил и address-list требуют подтверждения API, сохраняют ввод/ошибку и блокируют повторы/закрытие. В 9.29 перемещение Firewall требует ok=true, откатывает порядок при ошибке и использует общий замок. В 9.30 восстановление истории подключено к общему замку, ручное обновление и открытие истории заблокированы во время записи. В 9.31 добавлены схемы ответа Firewall/серверов, сохранение последних данных при ошибке и поколение чтения с отменой применения при начале записи. В 9.32 backend сообщает частичные сбои по устройству/семейству/таблице, UI сохраняет недоступные срезы и блокирует изменения неполного среза. Далее — свежесть и дополнительные проверки контекста Firewall, затем Filters; ограничения счётчика, свежести и записи VXLAN остаются открытыми; загрузка данных IPsec и общая приёмка остаются открытыми.
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 303bd57a..ea2c8e45 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -25,12 +25,21 @@
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
-| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.31 — IPsec/GRE/VXLAN и переключение Firewall |
+| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.32 — IPsec/GRE/VXLAN и переключение Firewall |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
+## Блок 9.32 — частичные сбои Firewall (2026-09-30)
+
+- pollFirewallChunks опрашивает восемь таблиц правил и два address-list, сохраняет успешные срезы и возвращает failures с family/table/error. Повреждённый контейнер также считается сбоем. listFirewallAll добавляет устройство и сообщает общий сбой вместо маскировки успешным пустым списком.
+- fetchFirewallState по умолчанию отклоняет неполное чтение: captureFirewallSnapshot и восстановление не используют неполный снимок. Для обзора явно разрешён partial; observed-ревизия записывается только при полном чтении. Ошибка записи ревизии возвращается как общий failure устройства. Реальная БД и RouterOS не проверены. Подтверждение применения после операции, если последующее чтение снимка не удалось, требует внешней приёмки: write-endpoints по-прежнему могут сообщить ошибку после фактического изменения устройства.
+- UI валидирует failures, заменяет успешные срезы и сохраняет последние правила/address-list недоступных срезов включённых устройств. Сводка неполного выбранного IP-семейства неизвестна; предупреждение содержит устройство/срез/ошибку. Запись в этом срезе заблокирована до повторного успеха, восстановление — при любом failure выбранного устройства. Успешное пустое чтение удаляет старые строки; первоначальный сбой не выглядит успешным пустым списком.
+- Серверный тест прошёл: mixed/empty/all-failed/malformed, strict snapshot rejection и сохранение семи успешных таблиц при одном сбое. Тест подключён к test:live-maps. Прошли 35 разных E2E в двух прогонах: 32 прежних и три новых частичных сценария. Первый прогон нового сценария остановился на проверке скрытого комментария; проверка заменена видимым портом, профильные три теста прошли. TypeScript frontend/backend и сборка backend прошли. Новые frontend-файлы/Grid lint-чистые; исходные 4 ошибки/2 предупреждения страницы сохранены. Diff-check проверен перед коммитом. Последний frontend production build — 9.31.
+- Далее — timestamps свежести и дополнительные гонки Firewall при смене backend/режима и позднем завершении мутации; затем Filters. Полная валидация сырой записи RouterOS и интеграция записи observed-ревизий с реальной БД не проверены. Этап 9 открыт.
+- Квота: 38% пятичасовой/6% недельной на старте, 42%/7% после проверок. Локальный коммит без push; чужие изменения сохранены.
+
## Блок 9.31 — проверка и загрузка данных Firewall (2026-09-30)
- Общие Zod-схемы проверяют контейнер, обязательные поля правил/address-list, IP-семейство, таблицу, boolean/числовые поля, ID устройств и дубли строк/серверов. Ответы обрабатываются как unknown до валидации; повреждённые данные не попадают в UI.
diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md
index d0b4109e..ee1765eb 100644
--- a/docs/ux-ui-stage-9.md
+++ b/docs/ux-ui-stage-9.md
@@ -165,3 +165,7 @@ POST требует ok=true, ошибка возвращает исходный
## Блок 9.31 — данные Firewall (2026-09-30)
Схемы отклоняют повреждённые ответы и дубли. Ошибка сохраняет последний список, первоначальные KPI неизвестны. Поколение запросов отменяет старое чтение при начале записи. Все 32 E2E, TypeScript и build (34 страницы) прошли; новых замечаний lint нет. Далее — частичные сбои опроса backend: safeGet/listFirewallAll всё ещё маскируют ошибку пустым списком. Свежесть, дополнительные гонки backend/режима/мутации и реальные устройства ещё не проверены.
+
+## Блок 9.32 — частичные сбои Firewall (2026-09-30)
+
+Backend сообщает failures по срезу, неполный snapshot отклоняется и не записывается как observed. UI сохраняет недоступные срезы, показывает неизвестную сводку и блокирует изменения до повторного успеха. Серверный тест, 35 разных E2E в двух прогонах, TypeScript frontend/backend и backend build прошли; новых frontend lint замечаний нет. Далее — свежесть/гонки контекста Firewall, затем Filters. Реальные RouterOS/БД и последствия ошибки снимка после записи ещё требуют приёмки; последний frontend build — 9.31.
diff --git a/lib/firewall-response.ts b/lib/firewall-response.ts
index 54c9fabb..fe22c0c7 100644
--- a/lib/firewall-response.ts
+++ b/lib/firewall-response.ts
@@ -1,7 +1,11 @@
import { z } from "zod"
const context = { id: z.string().min(1), rosId: z.string().min(1), serverId: z.string().min(1), serverName: z.string(), family: z.enum(["ip", "ip6"]) }
+export const firewallFailureSchema = z.object({ serverId: z.string().min(1), serverName: z.string().optional(), family: z.enum(["ip", "ip6"]).optional(), table: z.enum(["filter", "nat", "mangle", "raw", "address-list"]).optional(), error: z.string() })
+export type FirewallFailure = z.infer
+
export const firewallResponseSchema = z.object({
+ failures: z.array(firewallFailureSchema).default([]),
rules: z.array(z.object({
...context, table: z.enum(["filter", "nat", "mangle", "raw"]), chain: z.string(), action: z.string(),
proto: z.string(), src: z.string(), dst: z.string(), port: z.string(), iface: z.string(),
@@ -20,3 +24,13 @@ export const firewallServersSchema = z.array(z.object({
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length)
export type FirewallBackendServer = z.infer[number]
+
+export function matchesFirewallFailure(failure: FirewallFailure, row: { serverId?: string; family?: string; table?: string }, addressList = false) {
+ return failure.serverId === row.serverId && (!failure.family || failure.family === row.family)
+ && (!failure.table || failure.table === (addressList ? "address-list" : row.table))
+}
+
+export function mergeFirewallRows(previous: T[], incoming: T[], failures: FirewallFailure[], enabledServerIds: Set, addressList = false): T[] {
+ return [...incoming.filter(row => !failures.some(f => matchesFirewallFailure(f, row, addressList))),
+ ...previous.filter(row => !!row.serverId && enabledServerIds.has(row.serverId) && failures.some(f => matchesFirewallFailure(f, row, addressList)))]
+}
diff --git a/tests/e2e/firewall-partial.spec.ts b/tests/e2e/firewall-partial.spec.ts
new file mode 100644
index 00000000..8a910568
--- /dev/null
+++ b/tests/e2e/firewall-partial.spec.ts
@@ -0,0 +1,73 @@
+import { test, expect, type Page } from "@playwright/test"
+
+const server = { id: 1, name: "Первый роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" }
+const rule = { id: "rule-1", rosId: "*1", serverId: "1", serverName: server.name, family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Первое правило", enabled: true, hits: 1 }
+const second = { ...rule, id: "rule-2", rosId: "*2", serverId: "2", serverName: "Второй роутер", comment: "Второе правило" }
+const address = { id: "address-1", rosId: "*A", serverId: "1", serverName: server.name, family: "ip", list: "trusted", address: "192.0.2.10", comment: "Адрес тест", disabled: false }
+async function setup(page: Page) {
+ const control: { payload: unknown } = { payload: { rules: [rule, second], addressLists: [address] } }
+ await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
+ await page.route("**/api/**", route => {
+ const path = new URL(route.request().url()).pathname
+ return route.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/firewall/all" ? control.payload
+ : path === "/api/servers" ? [server, { ...server, id: 2, name: "Второй роутер", host: "192.0.2.2" }] : {} })
+ })
+ return control
+}
+
+test("Firewall: сохраняет недоступную таблицу, обновляет другое устройство и восстанавливает пустой срез", async ({ page }) => {
+ const control = await setup(page)
+ await page.goto("/firewall")
+ const rows = page.locator("tr[data-row-id]")
+ await expect(rows).toHaveCount(2)
+ control.payload = { rules: [{ ...second, port: "8443" }], addressLists: [], failures: [{ serverId: "1", serverName: server.name, family: "ip", table: "filter", error: "Таблица недоступна тест" }] }
+ await page.getByRole("button", { name: "Обновить", exact: true }).click()
+ await expect(page.getByRole("alert").filter({ hasText: "Не все срезы Firewall получены" })).toContainText("Таблица недоступна тест")
+ await expect(rows).toHaveCount(2)
+ await expect(page.locator('tr[data-row-id="rule-1"]')).toContainText("443")
+ await expect(page.locator('tr[data-row-id="rule-2"]')).toContainText("8443")
+ await expect(page.getByLabel("Сводка Firewall")).toContainText("—")
+ await page.getByRole("option").filter({ hasText: "Второй роутер" }).click()
+ await expect(rows).toHaveCount(1)
+ await expect(rows.getByRole("switch")).toBeEnabled()
+ await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toHaveCount(0)
+ await page.getByRole("option").filter({ hasText: "Первый роутер" }).click()
+ await expect(rows.getByRole("switch")).toBeDisabled()
+ await expect(page.getByRole("button", { name: "История", exact: true })).toBeDisabled()
+ await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeDisabled()
+ control.payload = { rules: [second], addressLists: [] }
+ await page.getByRole("button", { name: "Обновить", exact: true }).click()
+ await expect(rows).toHaveCount(0)
+ await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toHaveCount(0)
+ await expect(page.getByLabel("Сводка Firewall")).not.toContainText("—")
+})
+
+test("Firewall: address-list сохраняется при сбое своего среза", async ({ page }) => {
+ const control = await setup(page)
+ await page.goto("/firewall?server=1")
+ await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
+ control.payload = { rules: [rule, second], addressLists: [], failures: [{ serverId: "1", family: "ip", table: "address-list", error: "Адреса недоступны тест" }] }
+ await page.getByRole("button", { name: "Обновить", exact: true }).click()
+ await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toBeVisible()
+ await page.getByRole("button", { name: /Адр. листы/ }).click()
+ await expect(page.getByText(address.address, { exact: true })).toBeVisible()
+ await expect(page.getByRole("switch")).toBeDisabled()
+ await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeDisabled()
+ control.payload = { rules: [rule, second], addressLists: [] }
+ await page.getByRole("button", { name: "Обновить", exact: true }).click()
+ await expect(page.getByText(address.address, { exact: true })).toHaveCount(0)
+ await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeEnabled()
+})
+
+test("Firewall: первичный сбой устройства не выглядит успешным пустым списком", async ({ page }) => {
+ const control = await setup(page)
+ control.payload = { rules: [], addressLists: [], failures: [{ serverId: "1", error: "Устройство недоступно тест" }] }
+ await page.goto("/firewall?server=1")
+ await expect(page.getByRole("alert").filter({ hasText: "Не все срезы" })).toContainText("Устройство недоступно тест")
+ await expect(page.getByRole("status").filter({ hasText: "Данные Firewall пока недоступны" })).toBeVisible()
+ await expect(page.getByLabel("Сводка Firewall")).toContainText("—")
+ control.payload = { rules: [rule], addressLists: [] }
+ await page.getByRole("button", { name: "Обновить", exact: true }).click()
+ await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
+ await expect(page.getByRole("switch")).toBeEnabled()
+})