fix(ui): сохранять данные IPsec при сбоях опроса
This commit is contained in:
1 parent
8fab2f7fd2
commit
b8923d075d
7 files changed
+191
-48
No files matched your search
+68
-42
@@ -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<Server[]>([])
|
||||
const [failures, setFailures] = useState<IpsecFailure[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dataError, setDataError] = useState<string | null>(null)
|
||||
const [inventoryError, setInventoryError] = useState<string | null>(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<string | null>(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<BackendServer[]>(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<unknown>(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: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "clients",
|
||||
label: "Клиентов",
|
||||
value: scopedClients.length,
|
||||
value: scopeUnknown ? "—" : scopedClients.length,
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "online",
|
||||
label: "Онлайн",
|
||||
value: clientsOnline,
|
||||
value: scopeUnknown ? "—" : clientsOnline,
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "ca",
|
||||
label: "CA",
|
||||
value: caOk ? "ок" : "—",
|
||||
value: scopeUnknown ? "неизвестно" : caOk ? "ок" : "нет",
|
||||
icon: <KeyRoundIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{isLive && (dataError || inventoryError) ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>{dataError ? "Не удалось загрузить IPsec" : "Не удалось загрузить устройства"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{dataError || inventoryError}. {hasLoaded ? "Показаны последние полученные данные; их актуальность не подтверждена." : "Данные пока неизвестны."}
|
||||
<Button size="sm" variant="outline" disabled={loading} onClick={() => void loadLive()}>Повторить загрузку IPsec</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{failures.length > 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
|
||||
<AlertDescription>
|
||||
{failures.map((f) => f.serverName ?? f.serverId).join(", ")}
|
||||
{failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; ")}. Показаны последние данные недоступных устройств; актуальность не подтверждена.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -701,18 +720,20 @@ export default function IpsecPage() {
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<IpsecUsersGrid
|
||||
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">{loading ? "Загрузка клиентов IPsec…" : "Клиенты IPsec пока неизвестны"}</p> : <IpsecUsersGrid
|
||||
clients={filteredClients}
|
||||
emptyTitle={scopeUnknown ? "Клиенты IPsec пока неизвестны" : undefined}
|
||||
emptyDescription={scopeUnknown ? "Повторите опрос устройств, чтобы подтвердить состояние" : undefined}
|
||||
compactServer={compactServer}
|
||||
onDownloadCert={isLive ? (row) => void openCert(row) : undefined}
|
||||
onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined}
|
||||
onDelete={isLive ? (row) => setPendingDelete({ kind: "user", client: row }) : undefined}
|
||||
/>
|
||||
/>}
|
||||
</DataPageCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="server" className="mt-0 outline-none">
|
||||
<IpsecServerGrid
|
||||
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">Состояние серверов IPsec пока неизвестно</p> : <IpsecServerGrid
|
||||
servers={scopedSummaries}
|
||||
onInit={isLive ? (s) => { 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}
|
||||
/>
|
||||
/>}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cli" className="mt-0 outline-none">
|
||||
@@ -873,3 +894,8 @@ export default function IpsecPage() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function IpsecSource() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
return <IpsecPage key={JSON.stringify([mode, backendUrl])} />
|
||||
}
|
||||
@@ -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 (
|
||||
<EmptyState
|
||||
icon={<UsersIcon className="size-4" />}
|
||||
title="Нет клиентов IKEv2"
|
||||
description="Создайте клиента — сертификат и .p12 выпустятся автоматически"
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
|
||||
@@ -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 и последние коммиты; сохранять чужие изменения. Затем проверить квоту и выбрать завершённый по смыслу блок текущего этапа.
|
||||
|
||||
+10
-2
@@ -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, клавиатура и ПК/телефон.
|
||||
|
||||
@@ -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.
|
||||
+15
-2
@@ -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<IpsecListResponse>(baseUrl, `/api/ipsec${suffix}`, { method: "GET" })
|
||||
const response = await requestJson<unknown>(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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user