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})
- @@ -834,7 +834,7 @@ function AddressListsTab({ } /> - 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() { - @@ -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() +})