diff --git a/app/(main)/firewall/page.tsx b/app/(main)/firewall/page.tsx index c6b0b011..202a672a 100644 --- a/app/(main)/firewall/page.tsx +++ b/app/(main)/firewall/page.tsx @@ -21,6 +21,7 @@ import { type Server, } from "@/lib/data" import { useDataSource } from "@/lib/data-source" +import { firewallResponseSchema, firewallServersSchema, 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" @@ -1797,31 +1798,13 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) { // ─── Live API ───────────────────────────────────────────────────────────────── -interface BackendServer { - id: number - name: string - host: string - type: "jump-host" | "exit-node" | "home-router" - site: string - country: string - asn: string - enabled: boolean - status: "online" | "offline" | null - latency: number | null -} - -interface FirewallAllResponse { - rules: FirewallRule[] - addressLists: AddressListEntry[] -} - function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) } } -function mapBackendToServer(s: BackendServer): Server { +function mapBackendToServer(s: FirewallBackendServer): Server { return { id: String(s.id), name: s.name || s.host, @@ -1946,6 +1929,9 @@ function FirewallPageInner() { const [mockAddrLists, setMockAddrLists] = useState(INIT_ADDRESS_LISTS) const [dataLoading, setDataLoading] = useState(false) const [dataError, setDataError] = useState(null) + const [hasLoaded, setHasLoaded] = useState(false) + const loadEpoch = useRef(0) + useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive]) const [busy, setBusy] = useState(false) const [formError, setFormError] = useState(null) const mutationLock = useRef(false) @@ -1981,23 +1967,27 @@ function FirewallPageInner() { const loadLive = useCallback(async () => { if (!isLive) return + const epoch = ++loadEpoch.current setDataLoading(true) setDataError(null) try { const [backendServers, fw] = await Promise.all([ - apiFetch("/api/servers"), - apiFetch("/api/firewall/all"), + apiFetch("/api/servers"), + apiFetch("/api/firewall/all"), ]) - setLiveServers(backendServers.map(mapBackendToServer)) - setLiveRules(fw.rules) - setLiveAddrLists(fw.addressLists) + if (epoch !== loadEpoch.current) return + const servers = firewallServersSchema.safeParse(backendServers) + 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) + setHasLoaded(true) } catch (e) { + if (epoch !== loadEpoch.current) return setDataError(e instanceof Error ? e.message : "Ошибка загрузки") - setLiveServers([]) - setLiveRules([]) - setLiveAddrLists([]) } finally { - setDataLoading(false) + if (epoch === loadEpoch.current) setDataLoading(false) } }, [isLive, apiFetch]) @@ -2028,6 +2018,8 @@ function FirewallPageInner() { if (!isLive || !historyServerId) throw new Error("Выберите устройство в режиме Live") if (mutationLock.current) throw new Error("Дождитесь завершения текущей операции Firewall") mutationLock.current = true + loadEpoch.current += 1 + setDataLoading(false) setHistoryRestoring(true) try { const res = await apiFetch<{ ok: boolean }>( @@ -2047,17 +2039,14 @@ function FirewallPageInner() { }, [isLive, historyServerId, apiFetch, loadLive, loadRevisions]) useEffect(() => { - if (!isLive) { - queueMicrotask(() => { - setLiveServers([]) - setLiveRules([]) - setLiveAddrLists([]) - setDataError(null) - }) - return - } queueMicrotask(() => { - void loadLive() + setLiveServers([]) + setLiveRules([]) + setLiveAddrLists([]) + setHasLoaded(false) + setDataError(null) + setDataLoading(false) + if (isLive) void loadLive() }) }, [isLive, loadLive]) @@ -2167,6 +2156,8 @@ function FirewallPageInner() { const key = ruleKey(r) if (!key) return mutationLock.current = true + loadEpoch.current += 1 + setDataLoading(false) setTogglePending(true) setToggleError(null) setToggleStatus(`Ожидание изменения правила ${r.comment || r.rosId || r.id}…`) @@ -2199,6 +2190,8 @@ function FirewallPageInner() { if (!pendingDelete || mutationLock.current || busy || historyRestoring) return const target = pendingDelete mutationLock.current = true + loadEpoch.current += 1 + setDataLoading(false) setDeletePending(true) setDeleteError(null) try { @@ -2253,6 +2246,8 @@ function FirewallPageInner() { if (!dest) return const prev = source mutationLock.current = true + loadEpoch.current += 1 + setDataLoading(false) setMovePending(true) setMoveError(null) setMoveStatus("Ожидание перемещения правила " + (active?.comment || active?.rosId || activeId) + "…") @@ -2309,6 +2304,8 @@ function FirewallPageInner() { if (isLive) { mutationLock.current = true + loadEpoch.current += 1 + setDataLoading(false) setBusy(true) setFormError(null) try { @@ -2384,6 +2381,8 @@ function FirewallPageInner() { const key = addressKey(e) if (!key) return mutationLock.current = true + loadEpoch.current += 1 + setDataLoading(false) setTogglePending(true) setToggleError(null) setToggleStatus(`Ожидание изменения адреса ${e.address} (${e.list})…`) @@ -2424,6 +2423,8 @@ function FirewallPageInner() { if (isLive) { mutationLock.current = true + loadEpoch.current += 1 + setDataLoading(false) setBusy(true) setFormError(null) try { @@ -2529,8 +2530,9 @@ function FirewallPageInner() { >
{dataError && isLive && ( -
+
{dataError} + {hasLoaded && Показаны последние успешно загруженные данные.}
)} @@ -2558,28 +2560,28 @@ function FirewallPageInner() { { id: "rules", label: "Всего правил", - value: familyRules.length, + value: isLive && !hasLoaded ? "—" : familyRules.length, icon: , iconClassName: "text-muted-foreground", }, { id: "enabled", label: "Активных", - value: totalEnabled, + value: isLive && !hasLoaded ? "—" : totalEnabled, icon: , iconClassName: "text-success", }, { id: "drop", label: "Блокирующих", - value: dropRules, + value: isLive && !hasLoaded ? "—" : dropRules, icon: , iconClassName: "text-destructive", }, { id: "hits", label: "Срабатываний", - value: fmtHits(totalHits), + value: isLive && !hasLoaded ? "—" : fmtHits(totalHits), icon: , iconClassName: "text-info", }, @@ -2610,7 +2612,9 @@ function FirewallPageInner() { ))}
- {chainGroup === "address-lists" ? ( + {isLive && !hasLoaded ? ( +

{dataLoading ? "Загрузка данных Firewall…" : "Данные Firewall пока недоступны. Повторите загрузку."}

+ ) : chainGroup === "address-lists" ? ( new Set(rows.map(row => row.id)).size === rows.length), + addressLists: z.array(z.object({ ...context, list: z.string(), address: z.string(), comment: z.string(), disabled: z.boolean(), timeout: z.string().optional() })) + .refine(rows => new Set(rows.map(row => row.id)).size === rows.length), +}) + +export const firewallServersSchema = z.array(z.object({ + id: z.number().int().positive(), name: z.string(), host: z.string(), enabled: z.boolean(), country: z.string(), + type: z.enum(["jump-host", "exit-node", "home-router"]), site: z.string().default(""), asn: z.string().default(""), + status: z.enum(["online", "offline", "degraded"]).nullable().default(null), latency: z.number().finite().nullable().default(null), +})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length) + +export type FirewallBackendServer = z.infer[number] diff --git a/tests/e2e/firewall-data.spec.ts b/tests/e2e/firewall-data.spec.ts new file mode 100644 index 00000000..150e2eee --- /dev/null +++ b/tests/e2e/firewall-data.spec.ts @@ -0,0 +1,87 @@ +import { test, expect, type Page } from "@playwright/test" + +const rule = { id: "rule-1", rosId: "*R/1", serverId: "1", serverName: "Роутер", family: "ip", table: "filter", chain: "forward", action: "accept", proto: "tcp", src: "", dst: "", port: "443", iface: "", comment: "Правило тест", enabled: true, hits: 1 } +const server = { id: 1, name: "Роутер", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", status: "online" } +async function setup(page: Page) { + const control: { payload: unknown; servers: unknown; fail: boolean } = { payload: { rules: [rule], addressLists: [] }, servers: [server], fail: false } + await page.route("**/health", route => route.fulfill({ json: { ok: true } })) + await page.route("**/api/**", route => { + const path = new URL(route.request().url()).pathname + if (path === "/api/firewall/all" && control.fail) return route.fulfill({ status: 503, json: { error: "Загрузка недоступна тест" } }) + return route.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/firewall/all" ? control.payload : path === "/api/servers" ? control.servers : {} }) + }) + return control +} + +for (const invalid of ["container", "rule", "duplicate", "address", "servers"]) { + test(`Firewall: повреждённый ответ ${invalid} сохраняет последний список`, async ({ page }) => { + const control = await setup(page) + await page.goto("/firewall?server=1") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(1) + if (invalid === "servers") control.servers = [{ ...server, enabled: "yes" }] + else control.payload = invalid === "container" ? { rules: {} } : invalid === "rule" ? { rules: [{ ...rule, enabled: "yes" }], addressLists: [] } + : invalid === "duplicate" ? { rules: [rule, rule], addressLists: [] } : { rules: [rule], addressLists: [{ id: "address-1", address: null }] } + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Сервер вернул некорректные данные Firewall" })).toBeVisible() + await expect(rows).toHaveCount(1) + await expect(page.getByText("Показаны последние успешно загруженные данные.", { exact: false })).toBeVisible() + control.payload = { rules: [], addressLists: [] } + control.servers = [server] + 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: первый сбой показывает неизвестные KPI, повтор загружает данные", async ({ page }) => { + const control = await setup(page) + control.fail = true + await page.goto("/firewall?server=1") + await expect(page.getByRole("alert").filter({ hasText: "Загрузка недоступна тест" })).toBeVisible() + await expect(page.getByLabel("Сводка Firewall")).toContainText("—") + await expect(page.getByRole("status").filter({ hasText: "Данные Firewall пока недоступны" })).toBeVisible() + control.fail = false + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + control.fail = true + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Загрузка недоступна тест" })).toBeVisible() + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) +}) + +for (const fails of [false, true]) { + test(`Firewall: позднее чтение до мутации игнорируется ${fails}`, async ({ page }) => { + const control = await setup(page) + await page.goto("/firewall?server=1") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(1) + let release: (() => void) | undefined + const gate = new Promise(resolve => { release = resolve }) + let reads = 0 + await page.route("**/api/firewall/all", async route => { + reads++ + if (reads !== 1) return route.fallback() + await gate + return route.fulfill(fails ? { status: 500, json: { error: "Поздняя ошибка тест" } } : { json: { rules: [], addressLists: [] } }) + }) + await page.route("**/api/firewall/rules", route => { + expect(route.request().method()).toBe("PATCH") + control.payload = { rules: [{ ...rule, enabled: false }], addressLists: [] } + return route.fulfill({ json: { ok: true } }) + }) + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect.poll(() => reads).toBe(1) + await rows.getByRole("switch").click() + await expect(rows.getByRole("switch")).toHaveAttribute("aria-checked", "false") + await expect(rows.getByRole("switch")).toBeEnabled() + const response = page.waitForResponse(r => r.url().endsWith("/api/firewall/all")) + release!() + await (await response).finished() + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + await expect(rows).toHaveCount(1) + await expect(rows.getByRole("switch")).toHaveAttribute("aria-checked", "false") + await expect(page.getByRole("alert").filter({ hasText: "Поздняя ошибка тест" })).toHaveCount(0) + }) +}