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)
+})