From af84f0135dda4b5436196f64bab028051b412032 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 20 Sep 2026 21:59:57 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5?= =?UTF-8?q?=D0=B9=D1=81=20WireGuard=20=D1=81=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D1=82=D0=BE=D0=BC=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/wireguard/page.tsx | 91 +++++++++--- components/data-grids/wireguard-data-grid.tsx | 4 +- .../data-grids/wireguard-peers-grid.tsx | 2 +- docs/ux-ui-progress.md | 12 +- docs/ux-ui-stage-6.md | 2 +- hooks/use-dashboard-live.ts | 8 +- tests/e2e/wireguard-links.spec.ts | 129 ++++++++++++++++++ 7 files changed, 219 insertions(+), 29 deletions(-) create mode 100644 tests/e2e/wireguard-links.spec.ts diff --git a/app/(main)/wireguard/page.tsx b/app/(main)/wireguard/page.tsx index 500f778c..3242f1cc 100644 --- a/app/(main)/wireguard/page.tsx +++ b/app/(main)/wireguard/page.tsx @@ -1,6 +1,8 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" +import { Suspense, useCallback, useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { useRouter, useSearchParams } from "next/navigation" import { PageHeader } from "@/components/page-header" import { servers as mockServers } from "@/lib/data" import type { Server } from "@/lib/data" @@ -169,17 +171,22 @@ function peerRowId(iface: WgIfaceWithServer, peer: WgIfaceWithServer["peers"][nu return peer.id ?? peer.rosId ?? `${iface.id}-${peer.publicKey}-${index}` } -export default function WireGuardPage() { +function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterfacesHref, onSelectServer }: { + selectedServerId: string + selectedInterfaceId: string | null + allInterfacesHref: string + onSelectServer: (id: string) => void +}) { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const [liveIfaces, setLiveIfaces] = useState([]) const [liveServers, setLiveServers] = useState([]) const [failures, setFailures] = useState([]) - const [loading, setLoading] = useState(false) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(false) const [busy, setBusy] = useState(false) - const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [workspaceTab, setWorkspaceTab] = useState("interfaces") const [statusFilter, setStatusFilter] = useState("all") const [search, setSearch] = useState("") @@ -212,6 +219,7 @@ export default function WireGuardPage() { setLiveIfaces(wg.interfaces.map(dtoToRow)) setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer)) setFailures(wg.failures ?? []) + setLoadError(false) if (wg.failures?.length) { toast.warning( `Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`, @@ -219,8 +227,7 @@ export default function WireGuardPage() { } } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard") - setLiveIfaces([]) - setFailures([]) + setLoadError(true) } finally { setLoading(false) } @@ -243,10 +250,8 @@ export default function WireGuardPage() { const displayIfaces = isLive ? liveIfaces : collectMockInterfaces() const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled) - const effectiveServerId = - selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId) - ? selectedServerId - : ALL_SERVERS_ID + const effectiveServerId = selectedServerId + const selectedSourceError = isLive && (loadError || failures.some(f => f.serverId === selectedServerId)) const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId @@ -287,9 +292,12 @@ export default function WireGuardPage() { }, [isLive, historyServerId, backendUrl, loadLive, loadRevisions]) const scopedIfaces = useMemo(() => { + if (selectedInterfaceId !== null) return displayIfaces.filter(i => i.serverId === effectiveServerId && i.id === selectedInterfaceId) if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces return displayIfaces.filter((i) => i.serverId === effectiveServerId) - }, [displayIfaces, effectiveServerId]) + }, [displayIfaces, effectiveServerId, selectedInterfaceId]) + + const selectedInterface = selectedInterfaceId === null ? undefined : scopedIfaces[0] const statusCounts = useMemo(() => { let up = 0 @@ -458,7 +466,14 @@ export default function WireGuardPage() { } } + function sourceUnavailable(serverId: string | undefined) { + const unavailable = isLive && (loadError || failures.some(f => f.serverId === serverId)) + if (unavailable) toast.error("Обновите данные сервера перед изменением объекта") + return unavailable + } + async function handleToggle(iface: WgIfaceWithServer) { + if (sourceUnavailable(iface.serverId)) return if (!isLive || !iface.rosId) { toast.info("Доступно только в live-режиме") return @@ -476,6 +491,7 @@ export default function WireGuardPage() { async function confirmDelete() { if (!pendingDelete) return + if (sourceUnavailable(pendingDelete.iface.serverId)) return if (!isLive) { toast.info("Доступно только в live-режиме") setPendingDelete(null) @@ -501,6 +517,7 @@ export default function WireGuardPage() { } async function handleAddPeer(form: WgPeerFormState) { + if (sourceUnavailable(peerIface?.serverId)) return if (!isLive || !peerIface) { toast.info("Доступно только в live-режиме") return @@ -577,7 +594,8 @@ export default function WireGuardPage() {
+ {isLive && loadError && selectedInterfaceId === null && Не удалось обновить WireGuardПоказаны последние полученные данные. Повторите обновление перед изменением объектов.} + {selectedInterfaceId !== null && ( + + {selectedInterface ? "Выбран интерфейс: " + selectedInterface.name : "WireGuard по ссылке"} + +

{isLive && loading ? "Загрузка интерфейса…" : selectedSourceError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedInterface ? selectedInterface.serverName + " · " + (selectedInterface.address ?? selectedInterface.id) + ". Список, пиры и сводка ограничены выбранным интерфейсом." : "Интерфейс не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}

+
+ Все интерфейсы сервера + К дашборду +
+
+
+ )} openExport(iface, "rsc")} - onAddPeer={setPeerIface} - onToggle={handleToggle} - onDelete={(iface) => setPendingDelete({ kind: "iface", iface })} - onDeletePeer={(iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })} + onAddPeer={selectedSourceError ? undefined : setPeerIface} + onToggle={selectedSourceError ? undefined : handleToggle} + onDelete={selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })} + onDeletePeer={selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })} onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)} /> @@ -745,19 +776,19 @@ export default function WireGuardPage() { compactServer={compactServer} emptyAction={ scopedIfaces.length === 1 ? ( - ) : emptyCreateAction } - onDeletePeer={(row) => { - const iface = scopedIfaces.find((i) => i.id === row.ifaceId) + onDeletePeer={selectedSourceError ? undefined : (row) => { + const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId) if (!iface) return setPendingDelete({ kind: "peer", iface, peerId: row.id }) }} onExportPeer={(row) => { - const iface = scopedIfaces.find((i) => i.id === row.ifaceId) + const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId) if (!iface) return openExport(iface, "peer", row.id) }} @@ -895,3 +926,23 @@ export default function WireGuardPage() { ) } + +function WireGuardRoute() { + const params = useSearchParams() + const router = useRouter() + const serverId = params.get("server") ?? ALL_SERVERS_ID + const interfaceId = params.get("interface") + const rest = new URLSearchParams(params.toString()) + rest.delete("interface") + const allInterfacesHref = "/wireguard" + (rest.size ? "?" + rest.toString() : "") + return { + const next = new URLSearchParams(rest) + if (id === ALL_SERVERS_ID) next.delete("server") + else next.set("server", id) + router.push("/wireguard" + (next.size ? "?" + next.toString() : "")) + }} /> +} + +export default function WireGuardPage() { + return Загрузка WireGuard…
}> +} diff --git a/components/data-grids/wireguard-data-grid.tsx b/components/data-grids/wireguard-data-grid.tsx index 28a6085f..b8745f3f 100644 --- a/components/data-grids/wireguard-data-grid.tsx +++ b/components/data-grids/wireguard-data-grid.tsx @@ -273,7 +273,7 @@ function WireguardDataGrid({ manualPagination: true, data: interfaces, columns, - getRowId: (row) => row.id, + getRowId: (row) => JSON.stringify([row.serverId, row.id]), getRowCanExpand: () => true, }) @@ -293,7 +293,7 @@ function WireguardDataGrid({ table.getRow(row.id).toggleExpanded()} + onRowClick={(row) => table.getRow(JSON.stringify([row.serverId, row.id])).toggleExpanded()} tableClassNames={{ headerRow: "border-b border-border", bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"), diff --git a/components/data-grids/wireguard-peers-grid.tsx b/components/data-grids/wireguard-peers-grid.tsx index 8e0d2c16..7a181086 100644 --- a/components/data-grids/wireguard-peers-grid.tsx +++ b/components/data-grids/wireguard-peers-grid.tsx @@ -238,7 +238,7 @@ function WireguardPeersGrid({ manualPagination: true, data: peers, columns, - getRowId: (row) => row.id, + getRowId: (row) => JSON.stringify([row.serverId, row.ifaceId, row.id]), }) if (peers.length === 0) { diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 741be25b..4e86d120 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -20,7 +20,7 @@ | 3 | Основные таблицы и справочники | Завершён 2026-09-13, границы проверок ниже | | 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже | | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | -| 6 | Навигация и дашборд | В работе: готовы переходы к серверам, проверкам и GRE; остаются WireGuard/VXLAN | +| 6 | Навигация и дашборд | В работе: готовы переходы к серверам, проверкам, GRE и WireGuard; остаётся VXLAN | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Не начат | | 8 | Трафик и статистика | Не начат | | 9 | VPN, Firewall и Filters | Не начат | @@ -447,6 +447,16 @@ Production build успешен: контракты, TypeScript, 34 страни Квота: на старте использовано 69% пятичасовой, перед заключительными проверками 86% (недельной 45%). Следующий блок в этом окне не начинать. Сохранить локальным коммитом без push. **Следующий шаг: WireGuard, затем VXLAN** — URL с сервером и точным ID, запрет fallback на другой сервер, переиспользовать preserveSelection; проверить одинаковые имена/ID, прямой URL, Back, ошибки и отсутствие автоматических мутаций. Этап 6 остаётся открытым до завершения этих переходов и приёмки. +## Этап 6 — блок 7: переход к интерфейсу WireGuard (2026-09-20) + +Готов `/wireguard?server=&interface=` из очереди проблем, включая встроенные моки. Выбирается точная пара; таблица, пиры и сводка ограничены интерфейсом. Alert показывает сервер и адрес, ошибки и отсутствие объекта. Снятие выбора сохраняет сервер и остальные query; смена сервера снимает interface, Back/reload восстанавливают выбор. Используется preserveSelection без подстановки другого сервера. + +При ошибке HTTP последние данные сохраняются с предупреждением; изменяющие действия для устаревших интерфейсов/пиров блокируются. Частичные failures выбранного сервера показаны отдельно. Полная достоверность KPI при частичных сбоях и UX форм остаются этапом 9. Ключи строк интерфейсов включают сервер, пиров — сервер/интерфейс; раскрытие строк и поиск интерфейса для действий пира используют тот же контекст. + +Проверки: 9 новых сценариев wireguard-links и 2 прежних wireguard-tables passed; 6 сценариев dashboard-data passed. Проверены одинаковые ID/имена, отдельные строки и раскрытие, вкладка пиров, URL/Back/reload, 403/500/503, частичный сбой, восстановление и отсутствие запросов записи. Мобильный контекст 390 px просмотрен. ESLint всех изменённых TS-файлов успешен; production build/TypeScript, 34 страницы — успешно. Реальный RouterOS не проверен, используются HTTP-моки по схеме контрактов. + +Квота: 0% использовано на старте нового окна, 25% перед сборкой (недельной 51%). Блок сохранить локальным коммитом без push. Следующий шаг — VXLAN и итоговая приёмка переходов этапа 6. + ## Карта миграции (исходная инвентаризация этапа 0) Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация. diff --git a/docs/ux-ui-stage-6.md b/docs/ux-ui-stage-6.md index f8cc3a3c..570f1c46 100644 --- a/docs/ux-ui-stage-6.md +++ b/docs/ux-ui-stage-6.md @@ -49,4 +49,4 @@ HTTP-моки проверяют интерфейс и запросы. Реал ### Переходы к объектам -Готовы и проверены: /servers?server=, /uptime?probe= и /gre?server=&tunnel=. Идентификаторы кодируются encodeURIComponent, целевые страницы выбирают точный ID (для GRE — пару сервер/туннель) и сообщают об отсутствии/ошибке загрузки. Снятие выбора сохраняет остальные query-параметры. GRE использует preserveSelection серверной панели, чтобы не сбрасывать URL при загрузке/отсутствии устройства. Ещё не реализованы ссылки на конкретные WireGuard/VXLAN; для них нужен контекст сервера вместе с ID объекта. +Готовы и проверены: /servers?server=, /uptime?probe=, /gre?server=&tunnel=, /wireguard?server=&interface=. Идентификаторы кодируются encodeURIComponent, целевые страницы выбирают точный ID (для туннелей — пару сервер/объект) и сообщают об отсутствии/ошибке загрузки. Снятие выбора сохраняет остальные query-параметры. GRE и WireGuard используют preserveSelection серверной панели, чтобы не сбрасывать URL при загрузке/отсутствии устройства. Ещё не реализованы ссылки на конкретные VXLAN. diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts index 5b5cd368..fe7564ae 100644 --- a/hooks/use-dashboard-live.ts +++ b/hooks/use-dashboard-live.ts @@ -216,10 +216,10 @@ function mockOverlayItems(): OverlayItem[] { })) const wg: OverlayItem[] = mockServers.flatMap((s) => (s.wireGuardIfaces ?? []).map((iface) => ({ - id: `wg-${iface.id}`, + id: JSON.stringify(["wg", s.id, iface.id]), name: iface.name, kind: "wg" as const, - href: "/wireguard", + href: "/wireguard?server=" + encodeURIComponent(s.id) + "&interface=" + encodeURIComponent(iface.id), status: iface.status, })), ) @@ -330,10 +330,10 @@ export function useDashboardLive() { const wgItems: OverlayItem[] = wgRes.status === "fulfilled" ? wgRes.value.interfaces.map((iface) => ({ - id: `wg-${iface.id}`, + id: JSON.stringify(["wg", iface.serverId, iface.id]), name: iface.name, kind: "wg" as const, - href: "/wireguard", + href: "/wireguard?server=" + encodeURIComponent(iface.serverId) + "&interface=" + encodeURIComponent(iface.id), status: iface.status, })) : [] diff --git a/tests/e2e/wireguard-links.spec.ts b/tests/e2e/wireguard-links.spec.ts new file mode 100644 index 00000000..fdab790a --- /dev/null +++ b/tests/e2e/wireguard-links.spec.ts @@ -0,0 +1,129 @@ +import { test, expect, type Page } from "@playwright/test" +import { wgListResponseSchema } from "../../packages/contracts/src/wireguard" + +async function setup(page: Page, url: string, failed = 0) { + const control = { partial: false, failed, posts: [] as { path: string; body: unknown }[] } + const servers = [{ id: 1, name: "Источник", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "64512", status: "online", latency: 1, os: "7", model: "CHR", wanUplinks: [] }] + const interfaces = wgListResponseSchema.parse({ interfaces: ["1", "2"].flatMap(serverId => ["*/2", "other"].map(id => ({ id, rosId: id, serverId, serverName: serverId === "1" ? "Источник" : "Второй сервер", name: "wg-same", address: "192.0.2." + (serverId === "1" ? "11" : "22"), listenPort: 13231, mtu: 1420, comment: "", enabled: true, status: "down", peers: [{ id: "peer", rosId: "*P", publicKey: "fixture-key", allowedIps: [serverId === "1" ? "10.0.1.1/32" : "10.0.2.1/32"] }] }))) }).interfaces + servers.push({ ...servers[0], id: 2, name: "Второй сервер", host: "192.0.2.2" }); + const probes = ["p1", "p/2"].map(id => ({ id, srcServerId: "1", name: "Проверка сети", target: "192.0.2.10", filter: "main", rtt: null, loss: 100, status: "down", series: [0], enabled: true, showOnDashboard: false })) + await page.route("**/health", route => route.fulfill({ json: { ok: true } })) + await page.route("**/api/**", route => { + const request = route.request() + const path = new URL(request.url()).pathname + if (!["GET", "HEAD"].includes(request.method())) control.posts.push({ path, body: request.postDataJSON() }) + if (path === "/api/wireguard" && control.failed) return route.fulfill({ status: control.failed, json: { error: "Мониторинг недоступен" } }) + const json = path === "/api/auth/config" ? { required: false } + : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } + : path === "/api/servers" ? servers + : path === "/api/uptime/overview" ? { probes, resources: [] } + : path === "/api/uptime/speed-probes" ? { probes: [] } + : path === "/api/uptime/speed-test/runs" ? { runs: [] } + : path === "/api/bgp/sessions" ? [] + : path === "/api/wireguard" ? { interfaces: control.partial ? interfaces.filter(t => t.serverId !== "2") : interfaces, failures: control.partial ? [{ serverId: "2", error: "Unavailable" }] : [] } + : path === "/api/filters/gre-tunnels" ? { tunnels: [] } + : path === "/api/vxlan" ? { tunnels: [] } + : path === "/api/traffic/servers" ? { servers: [] } + : path === "/api/events" ? { events: [] } + : path === "/api/internet-path/latest" ? { snapshot: { sampledAt: "2026-09-20T00:00:00Z", servers, greTunnels: [], filtersRulesets: [], speedProbes: [], routeLookupByServerId: {}, wanRuntimeByHomeId: {} } } + : {} + return route.fulfill({ json }) + }) + await page.goto(url) + return control +} + + +test("WireGuard: переход из очереди выбирает сервер и точный ID без записи", async ({ page }) => { + const control = await setup(page, "/dashboard") + const link = page.getByRole("region", { name: "Требует внимания", exact: true }).locator('a[href="/wireguard?server=2&interface=*%2F2"]') + await expect(link).toBeVisible() + await link.click() + const context = page.getByRole("status", { name: "WireGuard по ссылке" }) + await expect(context).toContainText("Второй сервер") + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + await expect(page.getByRole("status", { name: "WireGuard по ссылке" })).toContainText("192.0.2.22") + await page.getByRole("tab", { name: /Пиры/ }).click() + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + await expect(page.locator("tr[data-row-id]")).toContainText("10.0.2.1/32") + expect(control.posts).toEqual([]) + await context.getByRole("link", { name: "К дашборду", exact: true }).click() + await expect(link).toBeVisible() +}) + +test("WireGuard: прямая ссылка, сброс, назад, reload и ошибка обновления", async ({ page }) => { + const control = await setup(page, "/wireguard?server=2&interface=*%2F2&keep=1") + const context = page.getByRole("status", { name: "WireGuard по ссылке" }) + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(1) + await context.getByRole("link", { name: "Все интерфейсы сервера", exact: true }).click() + await expect(page).toHaveURL(/server=2&keep=1$/) + await expect(rows).toHaveCount(2) + await page.goBack() + await expect(rows).toHaveCount(1) + await page.reload() + await expect(context).toContainText("Второй сервер") + control.failed = 503 + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(context).toContainText("сохранённые данные могут быть устаревшими") + await expect(rows).toHaveCount(1) + await page.locator("tr[data-row-id]").getByRole("button", { name: "Действия: wg-same" }).click() + await expect(page.getByRole("menuitem", { name: /Удалить/ })).toHaveCount(0) + await page.keyboard.press("Escape") + control.failed = 0 + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(context).toContainText("Список, пиры и сводка ограничены") + control.partial = true + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(context).toContainText("Не удалось получить данные выбранного сервера") + await expect(rows).toHaveCount(0) + expect(control.posts).toEqual([]) + await page.setViewportSize({ width: 390, height: 844 }) + await context.scrollIntoViewIfNeeded() + const box = await context.boundingBox() + expect(box!.x + box!.width).toBeLessThanOrEqual(390) + await page.screenshot({ path: "test-results/wireguard-link-mobile.png", animations: "disabled" }) +}) + +test("WireGuard: ручной выбор сервера снимает выбор туннеля и сохраняет query", async ({ page }) => { + const control = await setup(page, "/wireguard?server=2&interface=*%2F2&keep=1") + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + await page.getByRole("option").filter({ hasText: "Источник" }).click() + await expect(page).toHaveURL(/server=1&keep=1$/) + await expect(page.locator("tr[data-row-id]")).toHaveCount(2) + await expect(page.getByRole("status", { name: "WireGuard по ссылке" })).toHaveCount(0) + await page.goBack() + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + await expect(page.getByRole("status", { name: "WireGuard по ссылке" })).toContainText("192.0.2.22") + expect(control.posts).toEqual([]) +}) + +test("WireGuard: одинаковые ID сохраняют отдельные строки интерфейсов и пиров", async ({ page }) => { + await setup(page, "/wireguard") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(4) + expect(new Set(await rows.evaluateAll(elements => elements.map(el => el.getAttribute("data-row-id")))).size).toBe(4) + await rows.nth(2).click() + await expect(page.getByText("10.0.2.1/32", { exact: true })).toBeVisible() + await expect(page.getByText("10.0.1.1/32", { exact: true })).toHaveCount(0) + await page.getByRole("tab", { name: /Пиры/ }).click() + await expect(rows).toHaveCount(4) + expect(new Set(await rows.evaluateAll(elements => elements.map(el => el.getAttribute("data-row-id")))).size).toBe(4) +}) + +for (const query of ["server=missing&interface=*%2F2", "server=2&interface=missing", "interface=*%2F2"]) { + test("WireGuard: нет подмены объекта " + query, async ({ page }) => { + const control = await setup(page, "/wireguard?" + query) + await expect(page.getByRole("status", { name: "WireGuard по ссылке" })).toContainText("Интерфейс не найден или недоступен") + await expect(page.locator("tr[data-row-id]")).toHaveCount(0) + expect(control.posts).toEqual([]) + }) +} +for (const status of [403, 500]) { + test("WireGuard: ошибка API " + status, async ({ page }) => { + const control = await setup(page, "/wireguard?server=2&interface=*%2F2", status) + await expect(page.getByRole("status", { name: "WireGuard по ссылке" })).toContainText("Не удалось получить данные выбранного сервера") + await expect(page.locator("tr[data-row-id]")).toHaveCount(0) + expect(control.posts).toEqual([]) + }) +}