fix(ui): сохранять недоступные срезы Firewall

This commit is contained in:
Denozordec committed 2026-09-30 10:46:36 +07:00
1 parent 59440555cd
commit 5e6057842f
11 files changed
+228 -77

No files matched your search

+41 -29
View File
@@ -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({
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} записей</span>
<Button size="sm" onClick={onAdd}><PlusIcon className="size-4" />Добавить</Button>
<Button size="sm" disabled={mutationsDisabled} onClick={onAdd}><PlusIcon className="size-4" />Добавить</Button>
</div>
<div className="flex flex-col gap-3">
@@ -805,7 +805,7 @@ function AddressListsTab({
<span className="text-sm font-mono font-medium">{listName}</span>
<span className="text-xs text-muted-foreground">({listEntries.length})</span>
</div>
<Button variant="ghost" size="sm" className="h-6 text-xs gap-1" onClick={() => onAddToList(listName)}>
<Button variant="ghost" size="sm" className="h-6 text-xs gap-1" disabled={mutationsDisabled} onClick={() => onAddToList(listName)}>
<PlusIcon className="size-3" />Адрес
</Button>
</div>
@@ -834,7 +834,7 @@ function AddressListsTab({
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onEdit(e)}><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onEdit(e)}><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onToggle(e)}>
<PowerIcon className="size-4" />{e.disabled ? "Включить" : "Отключить"}
</DropdownMenuItem>
@@ -1930,6 +1930,7 @@ function FirewallPageInner() {
const [dataLoading, setDataLoading] = useState(false)
const [dataError, setDataError] = useState<string | null>(null)
const [hasLoaded, setHasLoaded] = useState(false)
const [failures, setFailures] = useState<FirewallFailure[]>([])
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<typeof pendingDelete>) {
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"}
>
<HistoryIcon className="size-4" />
@@ -2520,7 +2527,7 @@ function FirewallPageInner() {
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
<CodeXmlIcon className="size-4" />Экспорт .rsc
</Button>
<Button size="sm" onClick={openAdd}>
<Button size="sm" disabled={firewallIncomplete} onClick={openAdd}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
@@ -2550,6 +2557,11 @@ function FirewallPageInner() {
</span>
</div>
{firewallIncomplete && <div role="alert" className="rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm">
<p className="font-medium">Не все срезы Firewall получены</p>
<p>Сохранены последние данные недоступных срезов; их состояние неизвестно. Обновите данные перед изменениями.</p>
{scopedFailures.map((failure, index) => <p key={index}>{[failure.serverName || failure.serverId, failure.family, failure.table, failure.error].filter(Boolean).join(" · ")}</p>)}
</div>}
{moveStatus && <p role="status" className="text-sm text-muted-foreground">{moveStatus}</p>}
{moveError && <p role="alert" className="text-sm text-destructive">{moveError}</p>}
{toggleStatus && <p role="status" className="text-sm text-muted-foreground">{toggleStatus}</p>}
@@ -2560,28 +2572,28 @@ function FirewallPageInner() {
{
id: "rules",
label: "Всего правил",
value: isLive && !hasLoaded ? "—" : familyRules.length,
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : familyRules.length,
icon: <ShieldIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "enabled",
label: "Активных",
value: isLive && !hasLoaded ? "—" : totalEnabled,
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : totalEnabled,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "drop",
label: "Блокирующих",
value: isLive && !hasLoaded ? "—" : dropRules,
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : dropRules,
icon: <ShieldOffIcon className="size-4" />,
iconClassName: "text-destructive",
},
{
id: "hits",
label: "Срабатываний",
value: isLive && !hasLoaded ? "—" : fmtHits(totalHits),
value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : fmtHits(totalHits),
icon: <ListFilterIcon className="size-4" />,
iconClassName: "text-info",
},
@@ -2612,12 +2624,12 @@ function FirewallPageInner() {
))}
</div>
{isLive && !hasLoaded ? (
{isLive && (!hasLoaded || (firewallIncomplete && (chainGroup === "address-lists" ? familyAddrLists.length === 0 : filteredRules.length === 0))) ? (
<p role="status" className="text-sm text-muted-foreground">{dataLoading ? "Загрузка данных Firewall…" : "Данные Firewall пока недоступны. Повторите загрузку."}</p>
) : chainGroup === "address-lists" ? (
<AddressListsTab
entries={familyAddrLists}
mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring}
mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring || firewallIncomplete}
onAdd={() => openAddAddr()}
onAddToList={(list) => openAddAddr(list)}
onToggle={(e) => { void toggleAddr(e) }}
@@ -2659,11 +2671,11 @@ function FirewallPageInner() {
<FirewallRulesDataGrid
rules={filteredRules}
mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring}
mutationsDisabled={togglePending || deletePending || movePending || busy || historyRestoring || firewallIncomplete}
onToggle={(id) => { 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}
/>
</DataPageCard>
@@ -2758,7 +2770,7 @@ function FirewallPageInner() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePending}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={deletePending || togglePending || busy || historyRestoring} onClick={e => { e.preventDefault(); void confirmDelete() }}>Удалить</AlertDialogAction>
<AlertDialogAction variant="destructive" disabled={deletePending || togglePending || busy || historyRestoring || firewallIncomplete} onClick={e => { e.preventDefault(); void confirmDelete() }}>Удалить</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
+1 -1
View File
@@ -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"
},
+19 -43
View File
@@ -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<T>(fn: () => Promise<T[]>, fallback: T[] = []): Promise<T[]> {
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<Firewa
export async function listFirewallAll(): Promise<{
rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[]
failures: Array<Partial<FirewallChunkFailure> & { 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 }
@@ -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<typeof fromServer>
const server = { id: 1, name: "Router", host: "192.0.2.1" } as Parameters<typeof fetchFirewallState>[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")
+26
View File
@@ -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<RosFirewallFilter[]>
getFirewallAddressList: (family: FirewallFamily) => Promise<RosFirewallAddressList[]>
}) {
const failures: FirewallChunkFailure[] = []
async function read<T>(family: FirewallFamily, table: FirewallChunkFailure["table"], fetch: () => Promise<T[]>): Promise<T[]> {
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 }
}
@@ -290,7 +290,7 @@ function FirewallRulesDataGrid({
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onEdit(r)}>
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onEdit(r)}>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
+2 -2
View File
@@ -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 — остаются явно зафиксированными, без заявления о реализации.
+10 -1
View File
@@ -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.
+4
View File
@@ -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.
+14
View File
@@ -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<typeof firewallFailureSchema>
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<typeof firewallServersSchema>[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<T extends { serverId?: string; family?: string; table?: string }>(previous: T[], incoming: T[], failures: FirewallFailure[], enabledServerIds: Set<string>, 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)))]
}
+73
View File
@@ -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()
})