From b8923d075d7f325dc55bd9eaa3dc214e46282253 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 14:43:22 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20IPs?= =?UTF-8?q?ec=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=B1=D0=BE=D1=8F=D1=85=20?= =?UTF-8?q?=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/ipsec/page.tsx | 110 ++++++++++++++++---------- components/ipsec/ipsec-users-grid.tsx | 8 +- docs/ux-ui-plan.md | 2 + docs/ux-ui-progress.md | 12 ++- docs/ux-ui-stage-9.md | 8 ++ shared/api/ipsec.ts | 17 +++- tests/e2e/ipsec-data.spec.ts | 82 +++++++++++++++++++ 7 files changed, 191 insertions(+), 48 deletions(-) create mode 100644 tests/e2e/ipsec-data.spec.ts diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 3f26094f..f241af4c 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -1,5 +1,6 @@ "use client" +import { z } from "zod" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { PageHeader } from "@/components/page-header" import { servers as mockServers } from "@/lib/data" @@ -162,7 +163,15 @@ function mapBackendServer(s: BackendServer): Server { } } -export default function IpsecPage() { +const ipsecInventorySchema = z.array(z.object({ + id: z.number().int().positive(), name: z.string(), host: z.string(), enabled: z.boolean(), + country: z.string().optional().default("UN"), + type: z.enum(["jump-host", "exit-node", "home-router"]).optional(), + status: z.enum(["online", "offline", "degraded"]).optional(), + latency: z.number().nullable().optional(), asn: z.string().optional(), +})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length) + +function IpsecPage() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" @@ -171,6 +180,11 @@ export default function IpsecPage() { const [liveServers, setLiveServers] = useState([]) const [failures, setFailures] = useState([]) const [loading, setLoading] = useState(false) + const [dataError, setDataError] = useState(null) + const [inventoryError, setInventoryError] = useState(null) + const [hasLoaded, setHasLoaded] = useState(false) + const readEpoch = useRef(0) + useEffect(() => () => { readEpoch.current += 1 }, []) const [busy, setBusy] = useState(false) const [certBusy, setCertBusy] = useState(false) const [certError, setCertError] = useState(null) @@ -203,44 +217,35 @@ export default function IpsecPage() { const loadLive = useCallback(async () => { if (!isLive) return + const epoch = ++readEpoch.current setLoading(true) - try { - const [ipsec, servers] = await Promise.all([ - listIpsec(backendUrl), - requestJson(backendUrl, "/api/servers"), - ]) - setSummaries(ipsec.servers) - setClients(ipsec.clients) - setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer)) + const [ipsecResult, inventoryResult] = await Promise.allSettled([ + listIpsec(backendUrl), requestJson(backendUrl, "/api/servers"), + ]) + if (epoch !== readEpoch.current) return + if (inventoryResult.status === "fulfilled") { + const parsed = ipsecInventorySchema.safeParse(inventoryResult.value) + if (parsed.success) { + setLiveServers(parsed.data.filter(s => s.enabled).map(mapBackendServer)) + setInventoryError(null) + } else setInventoryError("Некорректный список устройств") + } else setInventoryError(inventoryResult.reason instanceof Error ? inventoryResult.reason.message : "Не удалось загрузить устройства") + if (ipsecResult.status === "fulfilled") { + const ipsec = ipsecResult.value + const failedIds = new Set((ipsec.failures ?? []).map(f => f.serverId)) + setSummaries(previous => [...ipsec.servers, ...previous.filter(s => failedIds.has(s.serverId))]) + setClients(previous => [...ipsec.clients, ...previous.filter(c => failedIds.has(c.serverId))]) setFailures(ipsec.failures ?? []) - if (ipsec.failures?.length) { - toast.warning( - `Не удалось опросить: ${ipsec.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`, - ) - } - } catch (e) { - toast.error(e instanceof Error ? e.message : "Ошибка загрузки IPsec") - setSummaries([]) - setClients([]) - setFailures([]) - } finally { - setLoading(false) + setHasLoaded(true) + setDataError(null) + } else { + setDataError(ipsecResult.reason instanceof Error ? ipsecResult.reason.message : "Ошибка загрузки IPsec") } + setLoading(false) }, [isLive, backendUrl]) useEffect(() => { - if (!isLive) { - queueMicrotask(() => { - setSummaries([]) - setClients([]) - setLiveServers([]) - setFailures([]) - }) - return - } - queueMicrotask(() => { - void loadLive() - }) + if (isLive) queueMicrotask(() => { void loadLive() }) }, [isLive, loadLive]) const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled) @@ -292,6 +297,9 @@ export default function IpsecPage() { } }, [isLive, historyServerId, backendUrl, loadLive, loadRevisions]) + const failedServerIds = new Set(failures.map(f => f.serverId)) + const scopeUnknown = isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId))) + const scopedClients = useMemo(() => { if (effectiveServerId === ALL_SERVERS_ID) return displayClients return displayClients.filter((c) => c.serverId === effectiveServerId) @@ -618,40 +626,51 @@ export default function IpsecPage() { { id: "servers", label: "Серверов IKEv2", - value: serversWithIpsec, + value: scopeUnknown ? "—" : serversWithIpsec, icon: , iconClassName: "text-muted-foreground", }, { id: "clients", label: "Клиентов", - value: scopedClients.length, + value: scopeUnknown ? "—" : scopedClients.length, icon: , iconClassName: "text-info", }, { id: "online", label: "Онлайн", - value: clientsOnline, + value: scopeUnknown ? "—" : clientsOnline, icon: , iconClassName: "text-success", }, { id: "ca", label: "CA", - value: caOk ? "ок" : "—", + value: scopeUnknown ? "неизвестно" : caOk ? "ок" : "нет", icon: , iconClassName: "text-primary", }, ]} /> + {isLive && (dataError || inventoryError) ? ( + + + {dataError ? "Не удалось загрузить IPsec" : "Не удалось загрузить устройства"} + + {dataError || inventoryError}. {hasLoaded ? "Показаны последние полученные данные; их актуальность не подтверждена." : "Данные пока неизвестны."} + + + + ) : null} + {failures.length > 0 ? ( Не удалось опросить часть роутеров - {failures.map((f) => f.serverName ?? f.serverId).join(", ")} + {failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; ")}. Показаны последние данные недоступных устройств; актуальность не подтверждена. ) : null} @@ -701,18 +720,20 @@ export default function IpsecPage() { ) : undefined } /> - {loading ? "Загрузка клиентов IPsec…" : "Клиенты IPsec пока неизвестны"}

: void openCert(row) : undefined} onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined} onDelete={isLive ? (row) => setPendingDelete({ kind: "user", client: row }) : undefined} - /> + />} - Состояние серверов IPsec пока неизвестно

: { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined} onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined} @@ -720,7 +741,7 @@ export default function IpsecPage() { onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined} onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined} onExportCert={isLive ? (serverId, cert) => void openCertByName(serverId, cert) : undefined} - /> + />}
@@ -873,3 +894,8 @@ export default function IpsecPage() { ) } + +export default function IpsecSource() { + const { mode, backendUrl } = useDataSource() + return +} diff --git a/components/ipsec/ipsec-users-grid.tsx b/components/ipsec/ipsec-users-grid.tsx index f20a53e5..ab53f730 100644 --- a/components/ipsec/ipsec-users-grid.tsx +++ b/components/ipsec/ipsec-users-grid.tsx @@ -31,6 +31,8 @@ export interface IpsecUsersGridProps { clients: IpsecClientDto[] compactServer?: boolean emptyAction?: ReactNode + emptyTitle?: string + emptyDescription?: string onDownloadCert?: (row: IpsecClientDto) => void onEdit?: (row: IpsecClientDto) => void onDelete?: (row: IpsecClientDto) => void @@ -40,6 +42,8 @@ function IpsecUsersGrid({ clients, compactServer = false, emptyAction, + emptyTitle = "Нет клиентов IKEv2", + emptyDescription = "Создайте клиента — сертификат и .p12 выпустятся автоматически", onDownloadCert, onEdit, onDelete, @@ -236,8 +240,8 @@ function IpsecUsersGrid({ return ( } - title="Нет клиентов IKEv2" - description="Создайте клиента — сертификат и .p12 выпустятся автоматически" + title={emptyTitle} + description={emptyDescription} action={emptyAction} className="border-0 py-16" /> diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 71f24952..f7e6d8c0 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -101,6 +101,8 @@ flowchart LR Снимки HTTP-фикстур просмотрены; в блоке 9.47 прошли общая регрессия Filters (33 E2E) и production build frontend (34 страницы). Этап 9 остаётся в работе; следующие блоки — ограничения IPsec/VXLAN и общего GRE-каталога по журналу. +В блоке 9.48 исправлены первоначальные и частичные ошибки чтения IPsec; далее readiness действий, свежесть и гонки этого раздела. + ## Порядок продолжения и журналирования Перед работой читать этот план, начало журнала, git status и последние коммиты; сохранять чужие изменения. Затем проверить квоту и выбрать завершённый по смыслу блок текущего этапа. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f1408534..841a5ed8 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -6,7 +6,7 @@ - План: ReUI PRO, Frame, base-nova; KpiStatGrid, ServerTileRail и CodeExportSheet остаются источниками общих композиционных решений. - Работа последовательно, без субагентов. После каждого завершённого этапа — локальный коммит на русском, без push. -- В начале этапа и после значимых проверок читать пятичасовой лимит. При остатке 25% и ниже не начинать следующий блок; закончить проверки и журнал. +- В начале этапа и после значимых проверок читать пятичасовой лимит. Базовый резерв 25%; по текущему прямому поручению пользователя продолжать до конца лимитов, сохраняя проверенные блоки локальными коммитами. - Этапы 2–4 выполнять в отдельной ветке codex/reui-v9-migration; не выпускать её до завершения миграции всех потребителей. - Локально доступны моки. Проверки операций должны идти по обычному API-пути с подменой HTTP-ответов в тестах: встроенный mock-режим обходит запросы и не доказывает корректность live-ветки. - Реальное применение RouterOS, сетевой опрос и экспорт сертификатов требуют отдельной приёмки в тестовом окружении. Не считать их проверенными по мокам. @@ -25,12 +25,20 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.47 — IPsec/GRE/VXLAN, Firewall и операции Filters | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.48 — IPsec/GRE/VXLAN, Firewall и операции Filters | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.48 — состояния чтения IPsec (2026-09-30) + +- listIpsec проверяет контракт, уникальность серверов/клиентов/отказов, конфликт успешного и неответившего устройства и контекст выборного ответа. Повреждённый ответ не меняет данные. IPsec и inventory загружаются независимо через allSettled; список устройств тоже проверяется схемой. +- Ошибка всего IPsec сохраняет последние строки; частичный отказ обновляет успешные устройства и сохраняет предыдущие строки/сводки неответивших. Новый подтверждённый пустой ответ удаляет прошлые строки. Постоянный alert даёт повтор; первоначальный отказ не изображает пустую таблицу, KPI неизвестного среза показаны «—»/«неизвестно». +- Поколение чтения игнорирует устаревшие ответы; смена mode/backend монтирует отдельное состояние. Поздние записи и доступность действий на сохранённых данных будут проверены следующим блоком, ещё не приняты. +- 17 E2E прошли: шесть новых сценариев чтения и 11 прежних операций. Ранее прошли три теста таблиц IPsec/GRE (два новых теста сперва требовали уточнения alert-локатора из-за Next route-announcer, исправлены). TypeScript и lint четырёх файлов чистые. Backend не менялся; последние build frontend — 9.47, backend — 9.44. +- Далее — readiness действий, свежесть и гонки IPsec; затем VXLAN/общий GRE. Реальные RouterOS/JWT/БД не проверены. Квота: 0% использования в начале, 4% пятичасовой/16% недельной после проверок. Локальный коммит без push. + ## Блок 9.47 — общая регрессия Filters (2026-09-30) - После блоков 9.35–9.46 последовательно прошли 27 E2E стандартной конфигурации и 6 E2E изоляции источника: данные/схемы, ошибки и повтор, каталоги, копирование и актуальная цель, удаление/порядок, история и общий замок, свежесть, гонки выбранного устройства, смена backend/mode, клавиатура и ПК/телефон. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 46cd9b01..9cc58d78 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -241,3 +241,11 @@ Live-копирование читает актуальный целевой rul - Production build frontend прошёл: контракты, TypeScript, 34 страницы. Последний backend build — 9.44, после него backend не менялся. Lint блока 9.46: 0 ошибок/13 прежних предупреждений. Исходный код в этом блоке не менялся. - Это профильная локальная регрессия Filters по HTTP-фикстурам, не полная регрессия приложения и не приёмка RouterOS/JWT/БД. Этап 9 остаётся открыт; этапы 10–12 не начаты. - Следующий шаг: проверить остаток квоты и последние файлы, разобрать первоначальные/частичные сбои IPsec; затем ограничения VXLAN и общего GRE-каталога. Пользователь разрешил продолжать до конца лимитов; после 33 E2E использовано 98% пятичасовой/15% недельной квоты. Результат сохраняется локальным коммитом без push. + +## Блок 9.48 — состояния чтения IPsec (2026-09-30) + +- listIpsec проверяет контракт, уникальность серверов/клиентов/отказов, конфликт успешного и неответившего устройства и контекст выборного ответа. Повреждённый ответ не меняет данные. IPsec и inventory загружаются независимо через allSettled; список устройств тоже проверяется схемой. +- Ошибка всего IPsec сохраняет последние строки; частичный отказ обновляет успешные устройства и сохраняет предыдущие строки/сводки неответивших. Новый подтверждённый пустой ответ удаляет прошлые строки. Постоянный alert даёт повтор; первоначальный отказ не изображает пустую таблицу, KPI неизвестного среза показаны «—»/«неизвестно». +- Поколение чтения игнорирует устаревшие ответы; смена mode/backend монтирует отдельное состояние. Поздние записи и доступность действий на сохранённых данных будут проверены следующим блоком, ещё не приняты. +- 17 E2E прошли: шесть новых сценариев чтения и 11 прежних операций. Ранее прошли три теста таблиц IPsec/GRE (два новых теста сперва требовали уточнения alert-локатора из-за Next route-announcer, исправлены). TypeScript и lint четырёх файлов чистые. Backend не менялся; последние build frontend — 9.47, backend — 9.44. +- Далее — readiness действий, свежесть и гонки IPsec; затем VXLAN/общий GRE. Реальные RouterOS/JWT/БД не проверены. Квота: 0% использования в начале, 4% пятичасовой/16% недельной после проверок. Локальный коммит без push. diff --git a/shared/api/ipsec.ts b/shared/api/ipsec.ts index c141df3d..f650c15f 100644 --- a/shared/api/ipsec.ts +++ b/shared/api/ipsec.ts @@ -1,4 +1,4 @@ -import { ipsecServerSummaryDtoSchema, ipsecUserCreatedSchema, ipsecCertBundleSchema } from "@mmapp/contracts/ipsec" +import { ipsecListResponseSchema, ipsecServerSummaryDtoSchema, ipsecUserCreatedSchema, ipsecCertBundleSchema } from "@mmapp/contracts/ipsec" import { requestJson } from "@/shared/api/http-client" import type { IpsecCertBundle, @@ -18,7 +18,20 @@ export async function listIpsec( const q = new URLSearchParams() if (opts?.serverId) q.set("serverId", opts.serverId) const suffix = q.size > 0 ? `?${q.toString()}` : "" - return requestJson(baseUrl, `/api/ipsec${suffix}`, { method: "GET" }) + const response = await requestJson(baseUrl, `/api/ipsec${suffix}`, { method: "GET" }) + const result = ipsecListResponseSchema.safeParse(response) + if (!result.success) throw new Error("Некорректный ответ загрузки IPsec") + const data = result.data + const serverIds = data.servers.map(s => s.serverId) + const clientIds = data.clients.map(c => c.id) + const failedIds = (data.failures ?? []).map(f => f.serverId) + if (new Set(serverIds).size !== serverIds.length || new Set(clientIds).size !== clientIds.length || + new Set(failedIds).size !== failedIds.length || serverIds.some(id => failedIds.includes(id)) || + data.clients.some(c => failedIds.includes(c.serverId)) || + (opts?.serverId && [...serverIds, ...failedIds, ...data.clients.map(c => c.serverId)].some(id => id !== opts.serverId))) { + throw new Error("Несогласованный ответ загрузки IPsec") + } + return data } export async function initIpsecServer( diff --git a/tests/e2e/ipsec-data.spec.ts b/tests/e2e/ipsec-data.spec.ts new file mode 100644 index 00000000..2df4a694 --- /dev/null +++ b/tests/e2e/ipsec-data.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from "@playwright/test" + +const summary = (id: string) => ({ serverId: id, serverName: "Роутер " + id, initialized: false, ike2Ready: false, natRuleManaged: false, clientsTotal: 1, clientsOnline: 0, peers: [] }) +const client = (id: string, name = "Клиент " + id) => ({ id: id + "-*1", rosId: "*1", serverId: id, serverName: "Роутер " + id, name, authMethod: "certificate", online: false, disabled: false, managed: true }) +const servers = [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU", type: "exit-node", status: "online" })) +const full = { servers: [summary("1"), summary("2")], clients: [client("1"), client("2")] } + +for (const broken of ["http", "payload", "duplicate", "conflict"]) { + test(`IPsec: неизвестное начало и сохранение данных при ${broken}`, async ({ page }) => { + let phase = 0 + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (path === "/api/ipsec") { + if (phase === 0) return r.fulfill({ status: 503, json: { error: "Начальный отказ IPsec" } }) + if (phase === 1 || phase === 3) return r.fulfill({ json: full }) + return broken === "http" ? r.fulfill({ status: 503, json: { error: "Повторный отказ IPsec" } }) + : r.fulfill({ json: broken === "payload" ? { servers: [], clients: [{}] } + : broken === "duplicate" ? { ...full, clients: [client("1"), client("1")] } + : { ...full, failures: [{ serverId: "1", error: "Сбой устройства" }] } }) + } + return r.fulfill({ json: path === "/api/servers" ? servers : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/ipsec") + const alert = page.getByRole("alert").filter({ hasText: "Не удалось загрузить IPsec" }) + await expect(alert).toContainText("Данные пока неизвестны") + await expect(page.getByText("Нет клиентов IKEv2", { exact: true })).toHaveCount(0) + await expect(page.locator("tr[data-row-id]")).toHaveCount(0) + await expect(page.getByLabel("Сводка IPsec")).toContainText("неизвестно") + phase = 1 + await alert.getByRole("button", { name: "Повторить загрузку IPsec" }).click() + await expect(page.locator("tr[data-row-id]")).toHaveCount(2) + phase = 2 + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(alert).toContainText("Показаны последние полученные данные") + await expect(page.locator("tr[data-row-id]")).toHaveCount(2) + await expect(page.getByLabel("Сводка IPsec")).toContainText("неизвестно") + phase = 3 + await alert.getByRole("button", { name: "Повторить загрузку IPsec" }).click() + await expect(alert).toHaveCount(0) + await expect(page.locator("tr[data-row-id]")).toHaveCount(2) + }) +} + +test("IPsec: частичный отказ сохраняет строки роутера, восстановление удаляет устаревшие", async ({ page }) => { + let phase = 0 + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + return r.fulfill({ json: path === "/api/ipsec" ? phase === 0 ? full : phase === 1 ? { + servers: [summary("1")], clients: [client("1", "Обновлённый клиент")], failures: [{ serverId: "2", serverName: "Роутер 2", error: "Таймаут тест" }], + } : { servers: [summary("1"), { ...summary("2"), clientsTotal: 0 }], clients: [client("1", "Обновлённый клиент")] } + : path === "/api/servers" ? servers : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/ipsec") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(2) + phase = 1 + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Не удалось опросить часть роутеров" })).toContainText("Роутер 2: Таймаут тест") + await expect(rows).toHaveCount(2) + await expect(rows.filter({ hasText: "Обновлённый клиент" })).toHaveCount(1) + await expect(rows.filter({ hasText: "Клиент 2" })).toHaveCount(1) + await expect(page.getByLabel("Сводка IPsec")).toContainText("неизвестно") + phase = 2 + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Не удалось опросить часть роутеров" })).toHaveCount(0) + await expect(rows).toHaveCount(1) + await expect(rows.filter({ hasText: "Клиент 2" })).toHaveCount(0) +}) + +test("IPsec: ошибка списка устройств не стирает успешный опрос", async ({ page }) => { + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (path === "/api/servers") return r.fulfill({ status: 503, json: { error: "Устройства недоступны тест" } }) + return r.fulfill({ json: path === "/api/ipsec" ? full : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/ipsec") + await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить устройства" })).toBeVisible() + await expect(page.locator("tr[data-row-id]")).toHaveCount(2) +})