diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx index 1d93c665..807dace4 100644 --- a/app/(main)/gre/page.tsx +++ b/app/(main)/gre/page.tsx @@ -13,6 +13,9 @@ import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/compone import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data" import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data" import { useDataSource } from "@/lib/data-source" +import { greResponseSchema } from "@/lib/gre-response" +import { vxlanServersSchema } from "@/lib/vxlan-response" +import type { z } from "zod" import { requestJson } from "@/shared/api/http-client" import { cn } from "@/lib/utils" import { toast } from "sonner" @@ -38,15 +41,11 @@ import { SheetDescription, SheetFooter, SheetClose, } from "@/components/ui/sheet" import { - DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, - DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup, -} from "@/components/ui/dropdown-menu" -import { - PlusIcon, RefreshCwIcon, MoreHorizontalIcon, - LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon, + PlusIcon, RefreshCwIcon, + LockIcon, ShieldCheckIcon, NetworkIcon, EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon, - CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, - DatabaseIcon, HistoryIcon, TriangleAlertIcon, + Trash2Icon, + DatabaseIcon, HistoryIcon, } from "lucide-react" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" @@ -147,47 +146,10 @@ function generateRosCommands(t: GreTunnel, serverById: Record): // ─── small ui helpers ──────────────────────────────────────────────────────── -function TunnelStatus({ status }: { status: GreStatus }) { - const s = greStatusMeta(status) - return ( - - - {s.label} - - ) -} - -function IpsecBadge({ secured }: { secured: boolean }) { - return secured ? ( - - IPsec - - ) : ( - - Открытый - - ) -} - // ─── Live API (как на /filters) ───────────────────────────────────────────── -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 GreTunnelsApiResponse { - tunnels: GreTunnel[] - failures?: Array<{ serverId: string; serverName?: string; error: string }> -} +type BackendServer = z.infer[number] +type InventoryServer = Server & { monitoringUnknown?: boolean } function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { @@ -195,19 +157,20 @@ function makeApiFetch(backendUrl: string) { } } -function mapBackendToServer(s: BackendServer): Server { +function mapBackendToServer(s: BackendServer): InventoryServer { return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", - site: s.site, + site: s.site ?? "", country: s.country || "UN", - asn: s.asn, - type: s.type, + asn: s.asn ?? "", + type: s.type ?? "exit-node", enabled: s.enabled, - status: (s.status ?? "offline") as Server["status"], + status: s.status ?? "offline", + monitoringUnknown: s.status == null, latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0, } @@ -261,11 +224,15 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on const isLive = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) - const [liveServers, setLiveServers] = useState([]) + const [liveServers, setLiveServers] = useState([]) const [liveTunnels, setLiveTunnels] = useState([]) const [dataLoading, setDataLoading] = useState(true) const [dataError, setDataError] = useState(null) - const [failedServerIds, setFailedServerIds] = useState([]) + const [failures, setFailures] = useState["failures"]>([]) + const failedServerIds = useMemo(() => failures.map(f => f.serverId), [failures]) + const [hasLoaded, setHasLoaded] = useState(false) + const loadEpoch = useRef(0) + useEffect(() => () => { loadEpoch.current += 1 }, []) const [pageTab, setPageTab] = useState("tunnels") const [tabFilter, setTabFilter] = useState("all") @@ -301,27 +268,30 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on const loadLive = useCallback(async () => { if (!isLive) return + const epoch = ++loadEpoch.current setDataLoading(true) - setDataError(null) try { - const [backendServers, greRes] = await Promise.all([ - apiFetch("/api/servers"), - apiFetch("/api/gre/tunnels"), + const [serversPayload, tunnelsPayload] = await Promise.all([ + apiFetch("/api/servers"), apiFetch("/api/gre/tunnels"), ]) - setLiveServers(backendServers.map(mapBackendToServer)) - setLiveTunnels(greRes.tunnels) - setFailedServerIds((greRes.failures ?? []).map(f => String(f.serverId))) + if (epoch !== loadEpoch.current) return + const inventory = vxlanServersSchema.safeParse(serversPayload) + const response = greResponseSchema.safeParse(tunnelsPayload) + if (!inventory.success || !response.success) throw new Error("Некорректный ответ загрузки GRE") + const failedIds = new Set(response.data.failures.map(f => f.serverId)) + const enabledIds = new Set(inventory.data.filter(s => s.enabled).map(s => String(s.id))) + setLiveServers(inventory.data.map(mapBackendToServer)) + setLiveTunnels(previous => [...response.data.tunnels, ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))]) + setFailures(response.data.failures) + setHasLoaded(true) + setDataError(null) setLiveStale(false) - if (greRes.failures?.length) { - toast.warning( - `Не удалось опросить: ${greRes.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`, - ) - } } catch (e) { + if (epoch !== loadEpoch.current) return setDataError(e instanceof Error ? e.message : "Ошибка загрузки") setLiveStale(true) } finally { - setDataLoading(false) + if (epoch === loadEpoch.current) setDataLoading(false) } }, [isLive, apiFetch]) @@ -354,12 +324,12 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on host: s.host, site: s.site, country: s.country, - status: s.status, + status: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status, type: s.type, enabled: s.enabled, - meta: String(displayTunnels.filter((t) => t.serverId === s.id).length), + meta: `${displayTunnels.filter((t) => t.serverId === s.id).length}${isLive && (s as InventoryServer).monitoringUnknown ? " · Статус неизвестен" : ""}`, })) - ), [displayServers, displayTunnels]) + ), [displayServers, displayTunnels, isLive]) const scopedTunnels = useMemo(() => { if (selectedTunnelId !== null) return displayTunnels.filter(t => t.serverId === selectedServerId && t.id === selectedTunnelId) @@ -374,13 +344,10 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on () => Object.fromEntries(displayServers.map((s) => [s.id, s])), [displayServers], ) - const poolById = useMemo( - () => Object.fromEntries(displayPools.map((p) => [p.id, p])), - [displayPools], - ) + const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId - const mutationsLocked = isLive && (mutateBusy || liveStale || selectedTunnelError || historyRestoring) + const mutationsLocked = isLive && (mutateBusy || dataLoading || !hasLoaded || liveStale || failures.length > 0 || selectedTunnelError || historyRestoring) useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive]) @@ -591,13 +558,12 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on setTunnelOpen(true) } - useEffect(() => { - if (dataError) toast.error(dataError) - }, [dataError]) + const incomplete = isLive && (!hasLoaded || !!dataError || failures.length > 0) + const unknownServerIds = useMemo(() => new Set(isLive ? dataError ? displayTunnels.map(t => t.serverId) : failedServerIds : []), [isLive, dataError, displayTunnels, failedServerIds]) const filtered = useMemo(() => { return scopedTunnels.filter((t) => { - if (tabFilter === "up" && t.status !== "up") return false + if (tabFilter === "up" && (t.status !== "up" || unknownServerIds.has(t.serverId))) return false if (tabFilter === "ipsec" && !t.ipsec) return false if (tabFilter === "plain" && t.ipsec) return false if (!search) return true @@ -609,7 +575,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on serverById[t.serverId]?.name.toLowerCase().includes(q) ) }) - }, [tabFilter, search, scopedTunnels, serverById]) + }, [tabFilter, search, scopedTunnels, serverById, unknownServerIds]) const upCount = displayTunnels.filter((t) => t.status === "up").length const ipsecCount = displayTunnels.filter((t) => t.ipsec).length @@ -638,14 +604,6 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on showAll allCount={displayServers.length} loading={isLive && dataLoading && displayServers.length === 0} - banner={ - isLive && liveStale ? ( -
- - Роутер недоступен — показан кэш. Изменения заблокированы, пока не удастся прочитать CHR. -
- ) : null - } header={ { void loadLive() }} - disabled={!isLive || dataLoading} + disabled={!isLive || dataLoading || mutateBusy || historyRestoring} title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"} > @@ -675,7 +633,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on История - @@ -697,6 +655,17 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on )} + {isLive && (dataError || failures.length > 0) && ( + + {dataError ? "Не удалось загрузить GRE" : "Не удалось опросить часть роутеров"} + +

{hasLoaded ? "Показаны последние полученные данные; актуальность отказавших устройств не подтверждена." : "Данные пока неизвестны. Пустой список не означает отсутствие туннелей."}

+

{dataError ?? failures.map(f => (f.serverName ?? f.serverId) + ": " + f.error).join("; ")}

+ +
+
+ )} + {/* Legacy banner */}
@@ -717,28 +686,32 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on { id: "tunnels", label: "Всего туннелей", - value: displayTunnels.length, + value: incomplete ? "—" : displayTunnels.length, + hint: incomplete ? "неизвестно" : undefined, icon: , iconClassName: "text-muted-foreground", }, { id: "up", label: "Активно", - value: upCount, + value: incomplete ? "—" : upCount, + hint: incomplete ? "неизвестно" : undefined, icon: , iconClassName: "text-success", }, { id: "ipsec", label: "Защищены IPsec", - value: ipsecCount, + value: incomplete ? "—" : ipsecCount, + hint: incomplete ? "неизвестно" : undefined, icon: , iconClassName: "text-info", }, { id: "pools", label: "IP-пулов", - value: displayPools.length, + value: incomplete ? "—" : displayPools.length, + hint: incomplete ? "неизвестно" : undefined, icon: , iconClassName: "text-primary", }, @@ -768,13 +741,13 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on options: tunnelTabs.map((t) => ({ value: t.value, label: t.label, - count: t.count, + count: incomplete ? undefined : t.count, })), }} search={search} onSearchChange={setSearch} searchPlaceholder="Поиск по имени, IP…" - countLabel={`${filtered.length} туннелей`} + countLabel={incomplete ? `${filtered.length} сохранённых строк · сводка неизвестна` : `${filtered.length} туннелей`} /> { void toggleTunnel(t) }} onDelete={setPendingDelete} mutationsLocked={mutationsLocked} + unknownServerIds={unknownServerIds} + emptyTitle={isLive && !hasLoaded ? dataLoading ? "Загрузка GRE…" : "Данные GRE неизвестны" : incomplete ? "Нет подтверждённых строк" : undefined} + emptyDescription={incomplete ? "Повторите опрос; отсутствие строк не подтверждено" : undefined} /> )} @@ -1158,6 +1134,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on } function GreRoute() { + const { mode, backendUrl } = useDataSource() const params = useSearchParams() const router = useRouter() const serverId = params.get("server") ?? ALL_SERVERS_ID @@ -1165,7 +1142,7 @@ function GreRoute() { const rest = new URLSearchParams(params.toString()) rest.delete("tunnel") const allTunnelsHref = "/gre" + (rest.size ? "?" + rest.toString() : "") - return { + return { const next = new URLSearchParams(rest) if (id === ALL_SERVERS_ID) next.delete("server") else next.set("server", id) diff --git a/components/data-grids/gre-tunnels-data-grid.tsx b/components/data-grids/gre-tunnels-data-grid.tsx index 4eb4e85d..9a030e86 100644 --- a/components/data-grids/gre-tunnels-data-grid.tsx +++ b/components/data-grids/gre-tunnels-data-grid.tsx @@ -105,6 +105,9 @@ interface GreTunnelsDataGridProps { onToggle?: (tunnel: GreTunnel) => void onDelete?: (tunnel: GreTunnel) => void mutationsLocked?: boolean + unknownServerIds?: Set + emptyTitle?: string + emptyDescription?: string } function GreTunnelsDataGrid({ @@ -116,6 +119,7 @@ function GreTunnelsDataGrid({ onToggle, onDelete, mutationsLocked = false, + unknownServerIds, emptyTitle, emptyDescription, }: GreTunnelsDataGridProps) { const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers]) const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools]) @@ -260,7 +264,7 @@ function GreTunnelsDataGrid({ id: "status", accessorKey: "status", header: ({ column }) => , - cell: ({ row }) => , + cell: ({ row }) => unknownServerIds?.has(row.original.serverId) ? Неизвестно : , meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, }, { @@ -337,7 +341,7 @@ function GreTunnelsDataGrid({ meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST }, }, ], - [onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, poolMap, serverMap], + [onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, unknownServerIds, poolMap, serverMap], ) const table = useTable({ @@ -345,15 +349,15 @@ function GreTunnelsDataGrid({ manualPagination: true, data: tunnels, columns, - getRowId: (row, index) => `${row.id}:${row.serverId}:${row.name}:${index}`, + getRowId: row => JSON.stringify([row.serverId, row.id]), }) if (tunnels.length === 0) { return ( } - title="Нет GRE-туннелей" - description="Измените фильтр или добавьте туннель" + title={emptyTitle ?? "Нет GRE-туннелей"} + description={emptyDescription ?? "Измените фильтр или добавьте туннель"} className="border-0 py-16" /> ) diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 07dab7a0..95cc1731 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -25,12 +25,19 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.57 — IPsec/GRE/VXLAN, Firewall и операции Filters | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.58 — IPsec/GRE/VXLAN, Firewall и операции Filters | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.58 — достоверность чтения GRE (2026-09-30) + +- Добавлена схема GRE с IPsec/PSK, уникальными составными serverId/id, уникальными failures и запретом одновременного успеха/отказа устройства. Inventory валидируется и принимает null мониторинга; rail показывает неизвестный статус. Ошибочный ответ не заменяет подтверждённые данные. +- Частичный отказ сохраняет строки отказавшего включённого устройства, успешный пустой опрос удаляет их. Постоянный alert содержит причину и повтор; начальный отказ не выглядит как отсутствие туннелей. Неполная сводка — неизвестная, отказавшие строки — «Неизвестно», фильтр активных исключает неподтверждённые статусы. Source/URL key и epoch исключают поздний результат чтения другого контекста. +- 22 E2E прошли: восемь сценариев данных плюс ссылки/формы/удаление/переключение. TypeScript и lint чистые. Удалены остатки неиспользуемых UI-хелперов; таблица использует стабильный составной ID. +- На время частичного отказа изменения пока блокируются для всего GRE. Следующий блок — readiness каждого устройства, общий lock записи и поздние результаты операций. Live-пулы/экспорт требуют дальнейшей проверки. Локальный коммит без push. + ## Блок 9.57 — устройства без снимка мониторинга (2026-09-30) - IPsec/VXLAN принимают законный status: null из /servers до первого опроса, а также прежний ответ без status. В rail такой статус обозначен «Статус неизвестен»; он не выдаётся за online/offline и не делает успешный VPN-опрос ошибкой. Внутренний совместимый Server DTO сохраняет fallback, но отображение использует отдельный признак monitoringUnknown. Filters/Firewall уже принимают null. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 57e87c8f..99e9af1b 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -321,3 +321,11 @@ Live-копирование читает актуальный целевой rul - 14 E2E прошли, включая четыре новых HTTP-сценария null/missing и регрессию ошибок/частичных отказов IPsec/VXLAN. TypeScript и lint чистые. Реальные устройства не опрашивались. - Далее — состояния данных GRE, затем readiness, изоляция операций и свежесть. Локальный коммит без push. + +## Блок 9.58 — достоверность чтения GRE (2026-09-30) + +- Добавлена схема GRE с IPsec/PSK, уникальными составными serverId/id, уникальными failures и запретом одновременного успеха/отказа устройства. Inventory валидируется и принимает null мониторинга; rail показывает неизвестный статус. Ошибочный ответ не заменяет подтверждённые данные. +- Частичный отказ сохраняет строки отказавшего включённого устройства, успешный пустой опрос удаляет их. Постоянный alert содержит причину и повтор; начальный отказ не выглядит как отсутствие туннелей. Неполная сводка — неизвестная, отказавшие строки — «Неизвестно», фильтр активных исключает неподтверждённые статусы. Source/URL key и epoch исключают поздний результат чтения другого контекста. +- 22 E2E прошли: восемь сценариев данных плюс ссылки/формы/удаление/переключение. TypeScript и lint чистые. Удалены остатки неиспользуемых UI-хелперов; таблица использует стабильный составной ID. +- На время частичного отказа изменения пока блокируются для всего GRE. Следующий блок — readiness каждого устройства, общий lock записи и поздние результаты операций. Live-пулы/экспорт требуют дальнейшей проверки. Локальный коммит без push. + diff --git a/lib/gre-response.ts b/lib/gre-response.ts new file mode 100644 index 00000000..20e39161 --- /dev/null +++ b/lib/gre-response.ts @@ -0,0 +1,14 @@ +import { z } from "zod" +import { filtersGreResponseSchema } from "./filters-response" + +export const greResponseSchema = z.object({ + tunnels: z.array(filtersGreResponseSchema.shape.tunnels.element.extend({ + ipsec: z.object({ + secret: z.string(), encAlg: z.enum(["aes-128", "aes-192", "aes-256"]).optional(), + authAlg: z.enum(["sha1", "sha256", "sha512"]).optional(), + dhGroup: z.enum(["modp1024", "modp2048", "modp4096", "ecp256", "ecp384", "ecp521"]).optional(), + ikeVersion: z.enum(["ikev1", "ikev2"]).optional(), lifetime: z.string().optional(), pfs: z.boolean().optional(), + }).nullable(), + })).refine(rows => new Set(rows.map(t => JSON.stringify([t.serverId, t.id]))).size === rows.length), + failures: z.array(z.object({ serverId: z.string().trim().min(1), serverName: z.string().optional(), error: z.string() })).default([]), +}).refine(data => new Set(data.failures.map(f => f.serverId)).size === data.failures.length && !data.tunnels.some(t => data.failures.some(f => f.serverId === t.serverId))) diff --git a/tests/e2e/gre-data.spec.ts b/tests/e2e/gre-data.spec.ts new file mode 100644 index 00000000..c065e9ff --- /dev/null +++ b/tests/e2e/gre-data.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "@playwright/test" + +const tunnel = (id: string, name = "Туннель " + id) => ({ id: "*1", serverId: id, name, localAddress: "192.0.2.1", remoteAddress: "192.0.2.2", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "live", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" }) +const servers = [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU", type: "exit-node", status: null, latency: null })) +const full = { tunnels: [tunnel("1"), tunnel("2")] } + +for (const broken of ["http", "payload", "duplicate", "conflict", "duplicate-failure", "blank-failure", "inventory"]) { + test(`GRE: неизвестное начало, сохранение кэша и восстановление ${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/servers") return r.fulfill({ json: phase === 2 && broken === "inventory" ? [servers[0], servers[0]] : servers }) + if (path === "/api/gre/tunnels") { + if (phase === 0 || phase === 2 && broken === "http") return r.fulfill({ status: 503, json: { error: "GRE недоступен тест" } }) + const failure = { serverId: "2", error: "Сбой роутера тест" } + return r.fulfill({ json: phase !== 2 ? full : broken === "payload" ? { tunnels: [{}] } + : broken === "duplicate" ? { tunnels: [tunnel("1"), tunnel("1")] } + : broken === "conflict" ? { ...full, failures: [failure] } + : broken === "duplicate-failure" ? { tunnels: [], failures: [failure, failure] } + : broken === "blank-failure" ? { tunnels: [], failures: [{ ...failure, serverId: " " }] } : full }) + } + return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/gre") + const alert = page.getByRole("alert").filter({ hasText: "Не удалось загрузить GRE" }) + await expect(alert).toContainText("Данные пока неизвестны") + await expect(page.getByText("Нет GRE-туннелей", { exact: true })).toHaveCount(0) + await expect(page.getByLabel("Сводка GRE")).toContainText("неизвестно") + phase = 1 + await alert.getByRole("button", { name: "Повторить загрузку GRE" }).click() + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(2) + await expect(page.getByRole("option").filter({ hasText: "Роутер 1" })).toContainText("Статус неизвестен") + phase = 2 + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(alert).toContainText("Показаны последние полученные данные") + await expect(rows).toHaveCount(2) + await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(2) + await expect(page.getByLabel("Сводка GRE")).toContainText("неизвестно") + phase = 3 + await alert.getByRole("button", { name: "Повторить загрузку GRE" }).click() + await expect(alert).toHaveCount(0) + await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(0) + }) +} + +test("GRE: частичный отказ сохраняет строки устройства, успешный пустой опрос очищает", 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/servers" ? servers : path === "/api/gre/tunnels" ? phase === 0 ? full : phase === 1 ? { tunnels: [tunnel("1", "Обновлённый GRE")], failures: [{ serverId: "2", serverName: "Роутер 2", error: "Таймаут тест" }] } : { tunnels: [tunnel("1", "Обновлённый GRE")] } : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/gre") + 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: "Туннель 2" })).toContainText("Неизвестно") + await expect(rows.filter({ hasText: "Обновлённый GRE" })).toContainText("Up") + phase = 2 + await page.getByRole("button", { name: "Повторить загрузку GRE" }).click() + await expect(rows).toHaveCount(1) + await expect(rows.filter({ hasText: "Туннель 2" })).toHaveCount(0) +}) diff --git a/tests/e2e/gre-links.spec.ts b/tests/e2e/gre-links.spec.ts index c9abbf46..3c45c296 100644 --- a/tests/e2e/gre-links.spec.ts +++ b/tests/e2e/gre-links.spec.ts @@ -69,7 +69,8 @@ test("GRE: прямая ссылка, сброс, назад, reload и ошиб control.partial = true await page.getByRole("button", { name: "Обновить", exact: true }).click() await expect(context).toContainText("Не удалось получить данные выбранного сервера") - await expect(rows).toHaveCount(0) + await expect(rows).toHaveCount(1) + await expect(rows).toContainText("Неизвестно") expect(control.posts).toEqual([]) await page.setViewportSize({ width: 390, height: 844 }) await context.scrollIntoViewIfNeeded()