From c7aec1500c0b07fade115cf16a0b53a083140851 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 4 Oct 2026 12:48:24 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=B7=D0=B0=D1=89=D0=B8=D1=89=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B5=20WireGuard?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/wireguard/page.tsx | 112 ++++++++++-------- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 7 +- docs/ux-ui-stage-12.md | 11 ++ .../screens/wireguard-read-error-320.png | Bin 0 -> 61448 bytes lib/wireguard-response.ts | 6 + playwright.config.ts | 2 +- shared/api/wireguard.ts | 11 +- tests/e2e/large-wireguard-peers.spec.ts | 2 +- tests/e2e/wireguard-context.spec.ts | 32 +++++ tests/e2e/wireguard-links.spec.ts | 50 ++++++++ 11 files changed, 182 insertions(+), 53 deletions(-) create mode 100644 docs/ux-ui/screens/wireguard-read-error-320.png create mode 100644 lib/wireguard-response.ts create mode 100644 tests/e2e/wireguard-context.spec.ts diff --git a/app/(main)/wireguard/page.tsx b/app/(main)/wireguard/page.tsx index 91160dcc..7b760951 100644 --- a/app/(main)/wireguard/page.tsx +++ b/app/(main)/wireguard/page.tsx @@ -3,6 +3,7 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" +import { wireGuardServersSchema } from "@/lib/wireguard-response" import { useWireGuardUrlState } from "@/hooks/use-wireguard-url-state" import type { WireGuardTab as WgWorkspaceTab } from "@/lib/wireguard-url-state" import { PageHeader } from "@/components/page-header" @@ -135,7 +136,7 @@ interface BackendServer { country: string type?: Server["type"] enabled: boolean - status?: Server["status"] + status?: Server["status"] | null latency?: number | null asn?: string } @@ -152,7 +153,7 @@ function mapBackendServer(s: BackendServer): Server { asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled, - status: s.status ?? "online", + status: s.status ?? "unknown", latency: s.latency ?? null, sessions: 0, } @@ -187,6 +188,12 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf const [failures, setFailures] = useState([]) const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(false) + const [readError, setReadError] = useState(null) + const [receivedAt, setReceivedAt] = useState(null) + const catalogKnown = !isLive || receivedAt !== null + const active = useRef(true) + const readRevision = useRef(0) + useEffect(() => { active.current = true; return () => { active.current = false } }, []) const [busy, setBusy] = useState(false) const toggleLock = useRef(false) const [togglePending, setTogglePending] = useState(null) @@ -227,27 +234,35 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf useEffect(() => () => { exportEpoch.current += 1 }, []) const loadLive = useCallback(async () => { - if (!isLive) return + if (!isLive || !active.current) return + const revision = ++readRevision.current setLoading(true) try { const [wg, servers] = await Promise.all([ listWireGuard(backendUrl), - requestJson(backendUrl, "/api/servers"), + requestJson(backendUrl, "/api/servers"), ]) - setLiveIfaces(wg.interfaces.map(dtoToRow)) - setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer)) + if (!active.current || revision !== readRevision.current) return + const parsedServers = wireGuardServersSchema.safeParse(servers) + if (!parsedServers.success) throw new Error("Ответ сервера содержит некорректный каталог устройств WireGuard") + const nextIfaces = wg.interfaces.map(dtoToRow), nextServers = parsedServers.data.filter(s => s.enabled).map(mapBackendServer) + setLiveIfaces(nextIfaces) + setLiveServers(nextServers) + setReceivedAt(Date.now()) + setReadError(null) setFailures(wg.failures ?? []) setLoadError(false) if (wg.failures?.length) { - toast.warning( + if (active.current) toast.warning( `Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`, ) } } catch (e) { - toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard") + if (!active.current || revision !== readRevision.current) return + setReadError(e instanceof Error ? e.message : "Ошибка загрузки WireGuard") setLoadError(true) } finally { - setLoading(false) + if (active.current && revision === readRevision.current) setLoading(false) } }, [isLive, backendUrl]) @@ -269,12 +284,14 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled) const effectiveServerId = selectedServerId - const selectedSourceError = !!urlError || isLive && (loadError || failures.some(f => f.serverId === selectedServerId)) + const selectedSourceError = !!urlError || isLive && (!catalogKnown || loadError || failures.some(f => f.serverId === selectedServerId)) + + const completeScope = catalogKnown && !urlError && (!isLive || !failures.some(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId)) const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId const loadRevisions = useCallback(async () => { - if (!isLive || !historyServerId) return + if (!isLive || !historyServerId || !active.current) return const epoch = ++historyEpoch.current setHistoryLoading(true) setHistoryError(null) @@ -301,7 +318,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf { method: "POST", body: JSON.stringify({ serverId: historyServerId }) }, ) if (result?.ok !== true) throw new Error("Сервер не подтвердил восстановление") - toast.success("Версия применена на роутер") + if (active.current) toast.success("Версия применена на роутер") await loadLive() await loadRevisions() } catch (err) { @@ -410,16 +427,16 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf country: s.country, status: s.status, type: s.type, - count: counts.get(s.id) ?? 0, + count: !catalogKnown || failures.some(f => f.serverId === s.id) ? undefined : counts.get(s.id) ?? 0, enabled: s.enabled, title: [s.name, s.host, s.asn].filter(Boolean).join(" · "), })) - }, [displayServers, displayIfaces]) + }, [displayServers, displayIfaces, catalogKnown, failures]) async function handleCreate(form: WgCreateFormState) { - if (urlError || formLock.current) return + if (urlError || !catalogKnown || loadError || formLock.current) return if (!isLive) { - toast.info("Создание на роутер доступно только в live-режиме") + if (active.current) toast.info("Создание на роутер доступно только в live-режиме") return } formLock.current = true @@ -449,7 +466,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf } : undefined, }) - toast.success(`Интерфейс ${form.name} создан`) + if (active.current) toast.success(`Интерфейс ${form.name} создан`) setCreateOpen(false) await loadLive() } catch (e) { @@ -468,7 +485,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf }) { if (formLock.current) return if (!isLive) { - toast.info("Импорт на роутер доступен только в live-режиме") + if (active.current) toast.info("Импорт на роутер доступен только в live-режиме") return } formLock.current = true @@ -482,10 +499,10 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf dryRun: args.dryRun, }) if (res.dryRun) { - toast.info("Конфигурация проверена без применения на роутер") + if (active.current) toast.info("Конфигурация проверена без применения на роутер") return } - toast.success( + if (active.current) toast.success( res.applied ? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)` : "Импорт выполнен", @@ -502,7 +519,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf function sourceUnavailable(serverId: string | undefined) { const unavailable = !!urlError || isLive && (loadError || failures.some(f => f.serverId === serverId)) - if (unavailable) toast.error("Обновите данные сервера перед изменением объекта") + if (unavailable && active.current) toast.error("Обновите данные сервера перед изменением объекта") return unavailable } @@ -510,7 +527,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf if (toggleLock.current) return if (sourceUnavailable(iface.serverId)) return if (!isLive || !iface.rosId) { - toast.info("Доступно только в live-режиме") + if (active.current) toast.info("Доступно только в live-режиме") return } toggleLock.current = true @@ -522,7 +539,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf disabled: iface.enabled, }) if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение интерфейса") - toast.success(iface.enabled ? "Отключено" : "Включено") + if (active.current) toast.success(iface.enabled ? "Отключено" : "Включено") await loadLive() } catch (e) { setToggleError(`${context}: ${e instanceof Error ? e.message : "Ошибка переключения"}`) @@ -536,7 +553,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf if (!pendingDelete || deleteLock.current) return if (sourceUnavailable(pendingDelete.iface.serverId)) return if (!isLive) { - toast.info("Доступно только в live-режиме") + if (active.current) toast.info("Доступно только в live-режиме") setPendingDelete(null) return } @@ -546,17 +563,17 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf try { if (pendingDelete.kind === "iface") { if (!pendingDelete.iface.rosId) { - toast.info("Доступно только в live-режиме") + if (active.current) toast.info("Доступно только в live-режиме") return } const result = await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId) if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление") - toast.success("Удалено") + if (active.current) toast.success("Удалено") } else { const peer = pendingDelete.iface.peers.find(p => p.id === pendingDelete.peerId || p.rosId === pendingDelete.peerId) const result = await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, peer?.rosId ?? pendingDelete.peerId) if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление") - toast.success("Пир удалён") + if (active.current) toast.success("Пир удалён") } setPendingDelete(null) await loadLive() @@ -572,7 +589,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf if (formLock.current) return if (sourceUnavailable(peerIface?.serverId)) return if (!isLive || !peerIface) { - toast.info("Доступно только в live-режиме") + if (active.current) toast.info("Доступно только в live-режиме") return } formLock.current = true @@ -593,7 +610,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined, comment: form.comment || undefined, }) - toast.success("Пир добавлен") + if (active.current) toast.success("Пир добавлен") setPeerIface(null) await loadLive() } catch (e) { @@ -628,10 +645,10 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf ? { conf: res.content } : { peerConf: res.content }), })) - toast.success("Конфиг загружен с роутера") + if (active.current) toast.success("Конфиг загружен с роутера") } catch (e) { if (epoch !== exportEpoch.current) return - toast.error(e instanceof Error ? e.message : "Ошибка экспорта") + if (active.current) toast.error(e instanceof Error ? e.message : "Ошибка экспорта") } finally { if (epoch === exportEpoch.current) { exportPending.current = null @@ -651,7 +668,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf } const emptyCreateAction = ( - @@ -665,7 +682,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf preserveSelection onSelect={onSelectServer} showAll - allCount={displayIfaces.length} + allCount={completeScope ? displayIfaces.length : undefined} loading={isLive && loading && displayServers.length === 0} header={ )} - - @@ -714,7 +731,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf >
{urlError && Проверьте ссылку

{urlError}

} - {isLive && loadError && selectedInterfaceId === null && Не удалось обновить WireGuardПоказаны последние полученные данные. Повторите обновление перед изменением объектов.} + {isLive && {loadError ? "Не удалось обновить WireGuard" : loading ? "Обновление WireGuard" : "Данные WireGuard"}{loadError && readError &&

{readError}

}{receivedAt !== null ?

Получены {new Date(receivedAt).toLocaleTimeString("ru-RU")}.{loadError ? " Показаны последние полученные данные. Повторите обновление перед изменением объектов." : loading ? " Выполняется обновление." : ""}

:

{loadError ? "Каталог пока неизвестен. Повторите обновление." : "Ожидание ответа сервера."}

}
} {selectedInterfaceId !== null && ( {selectedInterface ? "Выбран интерфейс: " + selectedInterface.name : "WireGuard по ссылке"} @@ -735,28 +752,28 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf { id: "ifaces", label: "Интерфейсов", - value: scopedIfaces.length, + value: completeScope ? scopedIfaces.length : "—", icon: , iconClassName: "text-muted-foreground", }, { id: "up", label: "Активных (UP)", - value: upIfaces, + value: completeScope ? upIfaces : "—", icon: , iconClassName: "text-success", }, { id: "peers", label: "Всего пиров", - value: totalPeers, + value: completeScope ? totalPeers : "—", icon: , iconClassName: "text-info", }, { id: "handshake-data", label: "Данные рукопожатия", - value: `${peersWithHandshakeData}/${totalPeers}`, + value: completeScope ? `${peersWithHandshakeData}/${totalPeers}` : "—", hint: Доступность пиров не проверена, icon: , iconClassName: "text-primary", @@ -810,7 +827,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf search={search} onSearchChange={setSearch} searchPlaceholder="Поиск по имени, серверу, IP…" - countLabel={`${filteredIfaces.length} интерфейсов`} + countLabel={catalogKnown && !urlError ? `${filteredIfaces.length} интерфейсов` : "Количество неизвестно"} segmented={{ value: statusFilter, onChange: setStatusFilter, @@ -821,7 +838,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf ], }} /> - 0) ? setPendingDelete({ kind: "iface", iface })} onDeletePeer={selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })} onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)} - /> + /> :

Каталог интерфейсов пока неизвестен

} @@ -842,9 +859,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf search={search} onSearchChange={setSearch} searchPlaceholder="Поиск по ключу, IP, endpoint…" - countLabel={`${peerRows.length} пиров`} + countLabel={catalogKnown && !urlError ? `${peerRows.length} пиров` : "Количество неизвестно"} /> - 0) ? + /> :

Каталог пиров пока неизвестен

} @@ -1011,6 +1028,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf } function WireGuardRoute() { + const { mode, backendUrl } = useDataSource() const params = useSearchParams() const router = useRouter() const serverId = params.get("server") ?? ALL_SERVERS_ID @@ -1018,7 +1036,7 @@ function WireGuardRoute() { const rest = new URLSearchParams(params.toString()) rest.delete("interface") const allInterfacesHref = "/wireguard" + (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/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index f4ded251..a1a3e8d5 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-04 -Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принят локально:BFD1000/50 DOM/cache/320px/12 browser/build. 12.4.4 принят локально:context213/25 файлов и unit36 прошли на5b2a291. 12.4.5 принят локально:main1110/171 прошли на21859be. 12.3.26 принят локально:strict atomic Ping write/подтверждение каталога и сохранения/история удаления/62 browser/context11/DI6/builds. 12.3.27 принят локально:WireGuard zero/missing counters/UI/backend mapper/29 browser/unit/builds. 12.3.28 принят локально:WireGuard interfaces1000/50 DOM/pinning/actions/cache/29+1 browser/build. 12.3.29 принят локально:WG peers1000/50 DOM/pinning/точный delete target/29+3 browser/build. 12.3.30 принят локально:WG detail1000/50 DOM/320px/клавиатура/31+1 browser/build. 12.3.31 принят локально:WG handshake без ложного online/unknown/7+1 browser/build. 12.3.32 принят локально:WG URL search/tab/status/reload/история/34+4 browser/unit/build. Следующий блок12.3.33: WireGuard read/cache/source context; далее network grids и полная main/context регрессия.12.5 отдельно. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются. +Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принят локально:BFD1000/50 DOM/cache/320px/12 browser/build. 12.4.4 принят локально:context213/25 файлов и unit36 прошли на5b2a291. 12.4.5 принят локально:main1110/171 прошли на21859be. 12.3.26 принят локально:strict atomic Ping write/подтверждение каталога и сохранения/история удаления/62 browser/context11/DI6/builds. 12.3.27 принят локально:WireGuard zero/missing counters/UI/backend mapper/29 browser/unit/builds. 12.3.28 принят локально:WireGuard interfaces1000/50 DOM/pinning/actions/cache/29+1 browser/build. 12.3.29 принят локально:WG peers1000/50 DOM/pinning/точный delete target/29+3 browser/build. 12.3.30 принят локально:WG detail1000/50 DOM/320px/клавиатура/31+1 browser/build. 12.3.31 принят локально:WG handshake без ложного online/unknown/7+1 browser/build. 12.3.32 принят локально:WG URL search/tab/status/reload/история/34+4 browser/unit/build. 12.3.33 принят локально:WG strict read/cache/freshness/unknown/40+7 browser/context8/build. Следующий блок12.3.34: большой каталог VXLAN; далее network grids и полная main/context регрессия.12.5 отдельно. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index bb1d6df6..d2bf96aa 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -29,12 +29,17 @@ | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | -| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13–12.3.25 приняты локально в описанных границах;12.4.4 context213 и12.4.5 main1110 прошли, 12.3.26 Ping write принят локально; остаются WireGuard counters/network grids/внешняя приёмка | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13–12.3.25 приняты локально в описанных границах;12.4.4 context213 и12.4.5 main1110 прошли, 12.3.26–12.3.33 приняты локально; остаются VXLAN/network grids/общая регрессия после delta/внешняя приёмка | +## 12.3.33 — достоверное чтение и контекст WireGuard (2026-10-04) +GET WireGuard проверяется до публикации: форма DTO, уникальные composite server/interface IDs и peer IDs внутри интерфейса, уникальность failed sources, отсутствие одновременно результата и failure одного устройства. Каталог устройств проверяется по subset общего serverRead контракта; оба списка преобразуются до изменения state/receipt time. Отсутствующий server status показан как unknown. Ошибочный ответ сохраняет последние данные; inline ReUI Alert показывает постоянную причину и время получения. Первоначально неизвестные и неопрошенные scope KPI показаны как —, failed device не становится подтверждённым пустым каталогом; sidebar count неизвестного устройства не подставляет0. +Mounted/revision guards исключают позднее чтение/ошибку старого источника; content key включает backend/mode. Старые операции не показывают toast в новом контексте и не запускают повторное чтение прежнего backend после unmount. Мутации неизвестного/error каталога закрыты; исходные API пути и правила сетевых операций сохранены. История также не загружается из obsolete component. +Проверки: финальный основной WireGuard профиль40/40; дополнительные strict identity/failures/device/cache сценарии7/7; context read/toggle × backend/mode × успех/ошибка8/8, повторён после strict device parser. TypeScript без incremental/ESLint и production frontend build прошли. [Ошибка/cache320px](ux-ui/screens/wireguard-read-error-320.png) просмотрена. Detail viewport test прокручивает первую карточку, resize проверка ожидает применения media layout; assertions не ослаблены. Добавлен wireguard-context в общий context config (новая общая матрица ещё целиком не повторена). +Scope proof ограничен HTTP-моками и read/toggle context сценариями; реальный RouterOS/PG не проверены. Backend fallback running/disabled/type и остальные source-context операции требуют дальнейшего аудита, strict frontend не доказывает достоверность отсутствующих raw RouterOS flags. Следующий блок12.3.34: VXLAN1000/50 DOM/refresh/search/sort/export/320px; затем остальные17 specialized grids и общая main/context приёмка. Внешняя12.5 отдельно. Коммит локальный без push. ## 12.3.32 — контекст фильтров WireGuard в URL (2026-10-04) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 7952e2ab..7554d13a 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -443,3 +443,14 @@ Peer grid теперь использует client pagination50, clamp и сох Следующий блок12.3.33: WireGuard чтение при смене backend/mode и поздних ответах, первоначально неизвестный каталог/KPI, cache/freshness и запрет изменения неизвестного источника. Затем оставшиеся17 specialized grids. Общая main/context после delta и внешняя12.5 остаются. Коммит локальный без push. + +## 12.3.33 — достоверное чтение и контекст WireGuard (2026-10-04) + +GET WireGuard проверяется до публикации: форма DTO, уникальные composite server/interface IDs и peer IDs внутри интерфейса, уникальность failed sources, отсутствие одновременно результата и failure одного устройства. Каталог устройств проверяется по subset общего serverRead контракта; оба списка преобразуются до изменения state/receipt time. Отсутствующий server status показан как unknown. Ошибочный ответ сохраняет последние данные; inline ReUI Alert показывает постоянную причину и время получения. Первоначально неизвестные и неопрошенные scope KPI показаны как —, failed device не становится подтверждённым пустым каталогом; sidebar count неизвестного устройства не подставляет0. + +Mounted/revision guards исключают позднее чтение/ошибку старого источника; content key включает backend/mode. Старые операции не показывают toast в новом контексте и не запускают повторное чтение прежнего backend после unmount. Мутации неизвестного/error каталога закрыты; исходные API пути и правила сетевых операций сохранены. История также не загружается из obsolete component. + +Проверки: финальный основной WireGuard профиль40/40; дополнительные strict identity/failures/device/cache сценарии7/7; context read/toggle × backend/mode × успех/ошибка8/8, повторён после strict device parser. TypeScript без incremental/ESLint и production frontend build прошли. [Ошибка/cache320px](ux-ui/screens/wireguard-read-error-320.png) просмотрена. Detail viewport test прокручивает первую карточку, resize проверка ожидает применения media layout; assertions не ослаблены. Добавлен wireguard-context в общий context config (новая общая матрица ещё целиком не повторена). + +Scope proof ограничен HTTP-моками и read/toggle context сценариями; реальный RouterOS/PG не проверены. Backend fallback running/disabled/type и остальные source-context операции требуют дальнейшего аудита, strict frontend не доказывает достоверность отсутствующих raw RouterOS flags. Следующий блок12.3.34: VXLAN1000/50 DOM/refresh/search/sort/export/320px; затем остальные17 specialized grids и общая main/context приёмка. Внешняя12.5 отдельно. Коммит локальный без push. + diff --git a/docs/ux-ui/screens/wireguard-read-error-320.png b/docs/ux-ui/screens/wireguard-read-error-320.png new file mode 100644 index 0000000000000000000000000000000000000000..bd4677550c5b804fc9db1427a577d6b39610c404 GIT binary patch literal 61448 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

tzz-0Z9hn)Z2)RRd0S5FW1Q_6vSu{Pf0 zq*_O{u*p|cXQSkUTGuFQ2W10bm5ST%t)lYXH#)F`U zY)Oqg2WMbv(zlNjA28)*)g_O-Se)pZl8CSJj;q8@QiTny?Y~4Czk6L?c(;9g&_BqR z$WvjMBzvVQORAo~FqEBqA(%}1m{2_jeU7&G_=P_C+b2pk6$5>PWzCBAsko@LnU32e zeK{v)KEk7~#yQ4#E<0E3Wd+}wT9E%7WHjHCmBR01qz|W|UtUUTVoanqG&JB8lq%r& z_7~7DrN}FZn^71p5zZ)CtY})gd#B*<9+!Kv;)fBw&`e-J>CK1;)>4X+Fu^PFKQ&o_ zT4bkrMs^us6I0Ez-%K?_7RhowcK4nWwIt^KO4ZOi&-e>_WtrSp)8qW7bZ5I8ZYirn zw-LNc@+`%z-!KApCJ|8|q3yGEV7>LW*xD+IZV#M*_RuTKI&qBy#EXOu-q#UwF6{Zz z&RIEQr$d#M^t1>`#?#W8g=tNIKgp5jCb^Xp&BsP>Qg6kr;Au|wPIuG9PVBsN^nRy9tbH2`0x{B3d-+?-uq$)O%Zu4$ zcz?M|YF0B1(I)PflUTDl@@Z?(^yPPeV`!ZOdAagKBZvP+1_pVW}R8fb+@gA z>9S@_JykXI1YYuPOe03ov1Qrv)qzyONn<{NyM&2-q4mis?)UL6Lf^Y1RmJ_`dQcA} zrpUfxlI9zQXYw+y|6bhE5hz?vnY@TNjUKmw-9pvO_wu~uWUM?%$nsAU!zzOO$aBRq zZoB$B`vTEbA5<^uqLC>(ijw>?T?&ct$$g)FQK4pz*HIDl{2N+%%T7N3PjGNN1KPhl3h+7P|3Q`cFP94luXR6}X^zu=~6X)HFN-1gLd98?OrY zyyB#=d7+y(15N0H8~a5`tA+ei#s(+!Js+P}fb;N|kA5lNKl6PD_o2UhU%MkK_l|Ou z|6{s&=J@;@_=XoMD>jME>z|zsiz@L|Il4>EjhV|l@wl9#f-Y8*{`>61|MCL-a(Mhy z@Rjjsz8YmhU}Ar)|ERQbv~X{y@e*}uBQr0aG-K9>S|+1%lS({7Z5KV6y+@X>p1W0? z#*{T@4s%I7_-8Vv@r);M{KR$k1C#p_AkRvg9siP=GFcHEi^~6pA+-J$w&~nYXV!d? z#!uI&ryW$SDJg5dC`tp}P`PydAi8si&L?L{RKc-Xo8A<-uxoWljmRsw{$0_Ef7TAT znTmn++{0|g&~0NmO5D{jGw+0@6T1%wr`39{#5x3^*WLE?!~N$b0or?>gV%=g0EMr- z9W|DSh_{=6H!av4Vb|KPxbiD`(XIW={h_bJe{qp0;J%IMGmJk+zdN(%D#1+}Ho~)4 zk;pgMB5$aGB1({|p(I{WvMl7&W9vF2C(;ie*=c{;i@EnLq@RxA*y#T2>oXkML#vmOxNs(*J;|a5O*h>P@JT? z<$z9E!avzU>9=g*_7v-NFR^(mW_?RKlHF>LPb?zx4n0|NZ9B_vGJeJ0)`$rG7&G-Y zbo#O6qqLOI4;oc}u)WC=&fSc{A(|R|9wM82d&#cZ*!sb+AqZ2cS<(3) z7gLP7PEYxGfu}q=I|uk$(xcXY5I)nIGt^asx2Y!}G>;~iBxDzzsoSvn>y#FGCfJ+O zPfm8*K7m|JmHSZ5=bZWKmx?UE=QUEueN(<-5_^+3l~tehn9?hdW5{JEXDB7hS_;WP z92&_fr6xJxR@Xn^QD-*mRpRXR?VuZK)k~3Xre{~6wdOAK4Yo^`Zzj#8tH0gm=np)s zJVGOBQtfws5^WW@oz7y#%zP}mB(AC^CWUg<(rQFB8K3U1LWM;k3#uy7FYMC$YTujg zTigPB2jRD`LzucEf@irYjhuOOWMr6x{Ejiq=W_v&%T0m? zEEM+7j7OQMza=}p?TJ~n5Po%dbX?SB5d2S(*>JRNwDV<9^iXcMar8DEiAiF~qp{m9 zOOSGCc-2Y}t+t<8*sXJ0`8&#BOUO|5)sW@X5-fckH9`ez?RZe7E%r_HDzr6zX=z|j z9#S^}y8z``;PlQ;yyA7@OkN&&?ys!8(Dt}&zBM}Y73$SnmfT9!*t1e57KD~aMM_`5zus6MnOfj&q6@BgZ}dIgZsAGv|=e&@$daSj9FOG zWc0i=c#@M#4-P5C0f?VW-o~cvyK2lRykQVbXkUlXFaRjYAJ;1)&4-yRoKuiekBmy0Sl`Dz=+%qVOV0W` zThQtN4Fw#9%75eBiGCj{LIWB61;J^1IB1W9Nef7m78r8b-Bp%aIvOI>7>f3rweN}# zj^{B0eRB4yHh02gRz-@c#qizUHXO)wLh#Tn zQ8%sOJb?Zg>a2C+%9=nyL7u+a)$$W&d21~tT!o<89v;2zO=~-n#kkdAr18eHVb$+F z5#Fgg+DlH;s13v+5x+j2k}!qE#nnNVFL4D7qszqCfOsArmhO*6H;msQX+u&4UaBn8 zl_`je&e9g{-Na1hes#BU3psy+X%#IxA}PL}AkkdFA>wX4u8s7Pz!t!G|3~G^R1s&H zPVikyE6%gIZSDS&uX@{#AY)ioYKsHIIwiwdDohP7;xBXKr^sjp#wiJQ$cxz_-Nn|U zVK6D9;T(~XchYj%>t*I9k)QD(Ny`)2HG|(ziV%E(9$rQukqi2c8-#GDtp{=ZTppum zk~0NIY(>l;LIMaZk`o0$w^tIzRB)%CraSe4VBhn{!{WJuRI9-Xaoe~DLEjdsdCp&o z47o^bTL1c_5`JjluCl6ygTOE&Kgv+A#$h|yYONFBUp>rX{eT1@ z(~xYR4<|PsE)3XVwl2qz3v?m*8G3xfH+s7gbu4fXv?*!($`&nG zX$rnDk?TLqC@`igw)*wYl`*Dd$shBqo;~vM#Ld}Msq*5^9SEGd z+S~jg^Dvoi5;kYUu8bNGXRlQ$u*$W{^gwRy-*6OU`2F)JTD3h$t22 z4NLnB0BfgYICj&{V=(Pemu<;^2l6nkQ<eLy!>X4LyQMc}-*j zpP(TD*$v!$#)H<5;=pS+_%#&RJOO>{$1Do zq3i$FwqN4s*CI;d{!aPpeBpFTUcqGhwNtH{m}hEpz^Qg2rBUv6F;!%w_o!ySbpH?F zrqFc$-)wGPUPA0x&{)@ws#0aoflZvFqt6OSPK;pA50%{ zrO~WjEKw1u?i#do#H8w1jSce^Y-@XvCZm3}jDms6T@4M04*Oe5uE5Z{I-i8*(UfT) z6N?qp$uHf>Z{t#AGWn(3RD;PwVsG(9G~%=WM-DdN6)vuBdfJ|$(YW+XIP~aSvs=`G za619DH*a>DNx+sW>|;{nCe7&h()2X?z%bUD%IS?nYD({F=L~Yl+5m{+nw*w90oev% zAVqLNe1lP8QWWPE%vO}5rx%o+zVAj?ETz{z8W8B3yxFJnN_n56`V(N;~ zP)dXg+;c*YG-({M(8XRCv9ARFv{dIWi;cPID?`ADphcK%?T}5_EqVr+pt?9cBSwG} zYc8ro=s8-=o`Ipv4EY!{wS0eP#~VG3B01jRi5maME{yQ_g%eQbB^*&@K*cdp2knrI zazJ*L4=B-`G-RJfxVxMbm*xe~{(#+k?=|qg@DT)|jttjIgeh|(l4iee9}T$#qXU6d zE8Sbxjw5qUSM3JaoGo%md%v@6{jr>Kd_GNm(@2`m`@^r9^xGnMG95{sO%30YRFcVK8(&S$WXpFF!3n z0YiUpZgyhGH%E2q{pzfLkttaRzHg40Q}jxNf=o&bg~>5SW+p9teUvL2)SB)ttc=S` zWom|&b`5qJ`Sor?sqru<*^GO+17@gv@M(?U_u<&`5-b2Z09tZi=NJM4kb!{%i^WF) z384EEDhOQ++ZdGsZE3g6@M@SY^9oYLCx8(~00@PGw!&jz%A(am(H4G%e)toJcU+bd zZCP|Fq+7R!>smwrk5^PEvTkVp|QN7#H?$Fa0EaN*D_do*GIT{JiE)UkhB!ZVG7yyfIUBmeQ zXCL8zqm|!+7?~+~66wMbJ(GHehfG+OW&qDmTwGj>ON**%YDvi#TFf}a{<}WzLgAU2 zL;;0E-JIq>jy~h3;-`|r0syqk`1tHuy}i&+gg7id=nNq}Ol)kk zQ+n@(ZPe62?`a0VtzDvdox8ZWnT3UgnHdcZBu=C3WCbF^@bEC#(o%9#5;s>yE0iag zSC98hNNpc0lc`xB-4R<3RnnbTrL&}jL||Ut?QQs~+Re9X6{H|ZPQgP%vlVIn4`?@UjdQeI< z_4Hy652;hWfd0rk%Qj3UYh~KhRQzC^UgV$!bDv(3+8g#!zJGZEgtUdc%{k@e5rigG ze48_5>7-GIeum6|Yj^_GPB#f!Y+5|0j|Aen4A^fx2+*a&i6`_V-zbzB6&f&sgM;IL zzJm&IY#{m?!NtXahg24}RaI1Asjdum z3{+8a;7Lh_`F>1VczE~*!-BwL9_mWiQ2i?xsyLyKc?R44%ZAW8#6}lu%vf=UJ+k9G zmI+wB42B9hIS-5XdRNr*Nx&+F@5q*wZ7w{ysfWrtV-gTtV;FP|#I^7R;T9kwBAOmA zizfN6x3K)SbYd7+a1$4YCz!zbEVRgGe$q&xkrWM#7lXhm6Q6CXF!m4FNH27{H&E_d>IKp2FScpi2m^^PA7q_9|5>pCBQ1zulYPe0fF5C(nBzCC)QBqf?S zuF|f?Ba7p8)jkxaz%*WO{im~`sW^M)Jk@Q&#|L3PZ~`z9XB2pT#>p9czbIx`X+%Or ztEaBKb5d5+v>+Bw3x206JE7AV^+93jwOPGj4Y|`FoOavbO^>a@6Jg0@kdw96seCgl zf^A={AL~Qd#VC(ZA(jSCZxNkLZoE-mgFslW*2|$VrhAtiXLT4Ob{dyY;YJrf&-A;N zmz?5+m2TrmDTihb2!WfdWnCZEf^ImJ;#M}ZcQ?%vtTL`|2zkZl0Q^d=?oBx#={R#U zMBME)2n%O1a}8FemhJJdfFc6oT-l5`B^Lr3d(g_PhWKI|w1_dcOCuT(g3Y8BvS4Zr zp!wCdoe!M%kdD|}QbBxb$hBxAr45bck~9q}dbn2!3@)TpW91va0_g9qpLW``D3pkq z*hcRH>W~a z7|WJevVxy9;U3pj+T!x#B5*&0&oad2x5cmhI>a^HuENj4+XO7Thi2E;cL#}sPPZcs zb@7ET+oolAdl+9&@_7ACWYvJJ%uYVB7 zf}W^oiz^_3;Pv1Dm3rQ8zy_&c`#N7MKgta^ABvtH5$WpTWS!NNB%jgdtZgj8E)vF> zb8&`$o|tBV%Crux_-Swz0&{4&FsGvIuxCgU_DTyc>a~0es!S(y5S2E@sQYs>LwgmC z7MhjoN>#|~qKjYX5AHMiF-f?eKo=4Z(?B2KeNU>^OYox>!qPeGP)n!KBp?6xlNb*m zcdAf+|6H2wjpbMNDHH~BFt9tXp6kT2lwcZ|GFFmug5;8xtaMvaOaxjE*g^t2(1s^n z;vnA*wwVA`Ba}cL3w8pH?BQW*o3gkG#I5G(W!1R>Ho@zk*>u^QN0U8FJ!33plkUG~ zu4=VmqUcQJqB7Lm%QlFR6c&Cx5L0kLY*{@;;|@$_Mn&6bok`7-nVRP>?Po<_YaL9) zgh!ikMi1yxuuon6+UA-Y?Mrwvu*AnlTzm&KAki)X2Umn!q8U!0|5f>#MecvUQh@>m z$uX!HNS;4bR;s4!Qz<$EarLL@X4EY1jv-r!gM~jQ)|!rLBUR(WK#sk0$UyEUOePU z(D-dsPjk5vp}`&WY2;cT#&S(fH6lU?XHXp*`jgbFYwQH)3b-OVm^U(Rs~%B1oAEEq z{nX_rCE3yN&ynnevj-tZEH2*tI8-Mh2!?2B8)>x2aE*F=iOXvznIP#@kSva{>1azaXs;&J(;!|2&tm8B}K+D2G zT9{~nkI@?Sf|SRTmTR^RPhTE{7^ReEK3DNgNj$AE*G#w~2B>ygN*fy&t?5(Zo#p`y zf0bXLwEcEHBu;)%znA-@xZ>c=lO}NcYip&3{Qg8lG{Dc%3X%^AoBx|{kGhA}rJ5le z6DED)o~glLwY3Jvl0q9V#g)G;MLeHW%99Q1rQQ1wIC{TOm6~km-bwTz@_UKY;Dqv* z+ohRer3uTf&(Sq8ZADk{!PYosPiGYeRel{qvyd^xlJ}Txc6&VHY#M$VNVOO=wlY&A`dB*VRSC=T|%P(3**fa+qK0 z`*JYZTh}9yQaov+;`1473{vSc$XM^R>)fvlvN?`uc^`av$~oMd@@WYJ>+)Z$MpIQ$ zW1klHm~-W8Yi@R3f3&x^XJeUOnn$R?$h8Z>tgtmRGaF0i4oDCZ6uM|5BO?Q$!|Q=7 zf)IH%HNIUNhoO@ch~{VnO{JQXA6@cHaqA3!HSA75!jvCfNg$}=k|MT@^b~-dm_6!4 zEV@w?0HKYsiXxCgVdLZD(b3VbnF5BWnjF5Tw)EK}Hd{{UItx_J@;ZZ7SJjrLuH=Zu zG`qRpdOWjELI>gmT{^9CC6Ub}@fav1W~g4U@^UeVU`>Iuv9a~m0wYX8m0*xY(4{=a&QbO?B?y9juT5H$XfWi2Z1s?ukjKdAzmd0|1O;fNkY}tud~24hs!E zhs6K~e0Cuq!QS#Ro*=ar#<>d?Xa{^|&Vvdi#NT+ppkKG5ZT5sJDbU38Ciw_(^u?Y6 zFDowYiU>iu0US{KZ7Mdy73r?OHdnT))_Y^2p;1~tHZ2-M zuA?*cLlKGp7Cem++>>!Y8I$3875x-p>piXdkt|Vdi`Ezp+c9R}iTZIcK-)YEKOzb! zgM}MIGO0XiJnyt;E>#0B_<;925(#U{cnap3gjfTIEjb%`5vrGOy+8AVx3O`&dro#S zvMh8j?Kz{5Y~8T6y;a65U$d+aKsyxtt(N(thK4C&8>($SBdV-N4{$}s43!t?})0M0ELH^FCTO3TX$H=ybG@c1a@Fw+vj zka0<EMq0w5qca{gBvEwE!fxw*K0O8oAI z0ac?zFd?vr-U4t9EiI{VgbU!FdHV+kI`1*@{d&aQz10nr42^--A?4+#d|=zOgJ zxkD)9wr=*}fonOEje+4a0B{YbSYBADA{hn=4}+LQbe|#}U?03=9d_<>@bKUak`qjp zWJc!Z5)U;|(b(SQCIN>o+Ol01C-}W0A{ZDLpgL1h{|pZRplaCcDV68ZzRoBLE^-Oj zRr^i!l%WMpUbjpBMwt=q_M`arY}m3NRwpxyAdRWCG`Z|O!?;b((xp=^6`42OECOZU`t@x2yc4pR|6W_r> zF==|hH6SFbs0arhatJa63h}*=9K;p^Hg2-Kl9HD#SO)Up(UBF+;`H1i8)6)pJPCuU@yeNDpD+VW5*A8v(j`4*}q#6PZ@p zWn>Bi_{@*c`r?E9{I9+|bCRU-vb!pz+1JJ51|b#yrDMa=)RYu*Vt97$D}w^$`8eaH z+xqBkJVuEo!I&*1xmHI&J4|PQ8F{*r12IUr{vTROXWzsOb5|2SVHCA8%D*C2DE`xJ zy`R-sWp_VYCO7v*1^Ve-_<6U{Z_D2ql9G~*zccUKkxyQ>d!eL;>yGsZpCkQh2*W+L zkAp^xq)=g&RRj=XIH${(tp?hkgAhgGPsQ9}xe%B0~Uu>Z=gPQ6j%?hY7* zGo3iE@?>7wtNNyYc>zUFCypI@@GayaG9ozm=BLL;`qH*rhHzzlC|$Z)^Fec}@hSyJ zyT%hFUNBBnJ^nEiYV6yZBW|CeE%EajRA%;G;^hYF`ZhEOz5nPXvuq5y6xS4vyRtRP zEPcaMmYFEI6szoH%P-4u! zum_9fz$EuHDOzXe2yZlKIMrCf6UV&LQIwX<>Rwb6mHC6@OfJE%PeB@#jUE?TZB%Iy z&yL(0@+s*dEEz=vYpnBJ)8BJR%X37JIac8ejAK7AOt&|Ui%odAM$;P4K8lV&)LA61W zj8SNjG;R5kvOhol(rT*r7nj0s*{xaj*b$tL_jm}Xfo9>r>s)oEk!W0=P`BS{N3K6W z7v&v&KZXaU2Z2(7I-D;->QduQ-+DlSY?Ey2yd%q6+i!Jkw^Nsp(#pedL)_<=iFWkk z_`6BS2P$9HKyYxpi`OHD1v+GE7+soKb=NIxP2SCUUOqC8-j3@g-#TEjIMZvaqvS^I z+Sm|5iK~?^rUPLNQcFGCH=IVkhg~bHy%xpiB0MaD&+RRh=eCs}19Uv-W1#mr9VH!- zIC?fG>8H11{EvP0D4ED<*@_N^d`n)sJCnJT0-0VMvS9t};1k$0qmCxSqBb&}>LVss z1{;^F1Ege56v_>LjLI07)X5uaNp|46ix)cq<$n5v*rn+@hr?%shVIIYtjGgEK)zq~ zU(p(~^1$FEEqg@C)a8~!kLgOV3WV9p`j~gK>Z;1#SJfGc)(S5iD4&O-$duWlF}ySG zgYaU5M|u$=>4MZ^w~>#xx~wmznrs`&ucnUl8`>`O*PA$4ftgfI#q1UNb(=s(F!u3r z`C?UwkJe3&AmSf6wD4NZiVyJ#du@`c8_hwCM*P&yg6U_oZ6!_9zH8^$*9eUSWhh=u z!zH)1tfn4|ekn?e&OObc$d5YvgA7XI)E$XKB%ppEaAp71s=hS4R6y-Q1FHjv#`r6u z`mHm=%N%eDj^&A?fy-Lj>F_LANHH3hGw$UDw9RWYVW2Bm>UB?THsm|KD^TEZj{kPT z*n;MlC71XB1ntr~s|Q>jrW$Fz2j5DYW2i<#i4_?pSCV)cK6jKCc-|ClgQoZ{2a(BK z{RYAU_O0rN_+O8()&s3J0|(_1HtER};i2eL$+(wNgdD4Fv?CPn zoGk7W2>kC<2~cgX=MNXU)cy*svi~f!=80JcUUjLQ_9N&k&X&I>$*QV$Zdlq8EPTh| zc%hktnkIbRm!2b0*9fOdn5j-E#_>Ojp2JMae}P%;#u22Yqobjw-Zwx>kh4EaQ*AO6 zKx=eQiq!l_e)&Fw5m8A>Yxs*|@R?vetGu|SR=tjPudn%68pG8dw4jVtMqdn;OgK!w zySXPecp=?Qj%ze4AC1Gt8Q4H$F=^ zxK(<>C0O|!Ktr?(_0FSL=(o}+(rP>(Dn@r1I)XX)nOQ0c0qEmzlS(XfIpOmYR3_Od1QL{2o)`aYzec)Qnx` zw0Stcpz(tR^uANQWrUcen+e{%18O7lRi&K|#W z4fQN3s{Kq=lOb6%J$^anZZaYQVA<7-X^rb@9a?(ClLo30>Qbmt@zqlGuNWBy_T_lK zHp$W;PS+y84r#pygt9GCtZ#T2b-CBJ6_?zWwmj{GXzfgahQoO;{FaMF*6en1VF4o8 zmcLI%Von7YbR~Q5=|8YPbF*sI^;@Bq3j-J$Jw06*R}$%8jW^vdj8@nUV-@=`!srO! zbvb-426!oF z-oGY9(W|k%4`?f@`0`uPQ@UDtbLy;3w$=yRGU0TMvgga_T{Z)`rYQmIf&tA zPbNP5Z5)&T^~98@<@jNcKWoL{`_H6m_XUvjjm2KT`FW`iDof3PCUM`2zGe{g{;Ar! zVB(B2wBBv;@oCYjFO-$m+7;qOC2#fJ1M+FO73LJz>CPm5ybXwP7rJMEUZ)E`xymrG;sK4g8gp z$cwqfV!q%P!_DwO`KY(87h|_^OVAfqDf5d0aT5)hEO@3>q2k1&5&VHcY$>yHG z0w5rsGeQ8289!?OpB+6=KhVL}mLU=15u`&sHMQ@x|AgW{kc|rXFO8{P5Ukf^|HvDV6yud&mzwZ^!l9D5!QeK(EcpPD1&zGx4 zjyA!}mr!DEZVq&`v0gNsdU|>#nqo{s=d35vZpad*4W%tz^ZosuomrWgnL)>?5P*+R zO3e*%!7!l&@(MC`XXCE${Jw+nQKfS zFT>gSVfzR*H8t!54h~PDwlO~Ew-7|03jc{Iiq~x18IZ~W!tn&JKVZyhmX?;*+SSsH z&?(~!0R@@y;<@z6FSbll^dJF%ogp@f7n!cTASiz&0K@U;EsXyTc*3k65q166p48AF zmxBR0P6VP{++2(On4F9K3B;=YJGFtCm=#`LUYTBC?g$bn!ftN(&0=soNtCT(0~=U4 zGU;c?_w3+8RN5LC@O#l6+S9YMH8ltw`LckFmhl^mmxNjGG=h+!ovMZ7#)pTxSt7Z< zNLJv2xf`>b+_F?bNeO5l*k|Ml%A%s8%Fh)N70Tl%j4Z4Sqmh-BqIP!Z-!K6W8Lh1Z z^;sa@bHOBlFc=YS2x|)Z_J+OzZwGXQnJ)jYvd%gxuAbrcgSEgEcXxMpDDLj=6n87` z?oeDx@#0XN!HPSBySqav4tJjCz3=+2{Ga4xB{^B=xA#uYP=CrlGin778}|%qYHIp! z^snTg!!9Lm3dg4dr7oDv|=dg1&p9%Fr9=BkRJVsImcw<^S zU4Gweoy!zO$m})g@tp*);(j<@=JzZ0gs2<8w@yVKrcwSo1x3qYgXfVDwefF4^M910 z%m)!eL$Hd8N+>Ku$HQZS^5W)cF@bVxGw}EKXKXBN3cfr$>u-rTI5>dlA&e&e{vfj4 z_z!?VWu;PLs1x@7{;=wj02CsM&5v(^e_9DCV>f;D_{Ctgkfvl8_WjQxlTy%?3Z(Lsj39cd!BiC3}*Q(=5^_| zT!MVR>QcaQ(IiIp5yzJOz5{h}(>TG=VS*=5<1=kC%cxcjUCX8a zy*m$dP#yVPo{o1v{5@Y$QYbKQ&E#7J=t4{gqsq<@anLhvP zWP9qq?s-r5nKQ}v!IwJhC@SP=rmB3B`7)u`Uv;FIjNSpRSj~dG=BHa zJ40KKUXNtaSIzbrQ~w$^zvMHU+)%7gI!(OcuW%@VC-;qh!m-}RYP{*sqAsR}63uc& zOBucP7$>Uc+|WXn$goj&_8?UJK}^#+f)}Q*T}fz6K%d^@M~pAPtN#@)P5z{g7j) zd-St-iInv2)}Yr{>F^@iXYpRts-ZV7o+k9GY)7IRM$|;?V>2m9PpSXv=a_t!Dxy*V z9mQ$F{D-%=qS=Y1RNP*8YNmqEsH6n>pi=Xg@OOpCg+MITP)eGk-sqLqf3<*R+IQ3X zJ=Z+}km8u|n_ zf3j&BcCui|C`q1Jq&Jbe$gHmKC5vtdy}G;7}p{_l{W{2aAwyluyo|fJMo)S|#a~-%EUf_7Z}k1b*## z%+z7<>n95ojouaxC2j-5%L~!KF-aq4&Xnra`k(I0cL(PDuoAd+_%?)L6{7=>e=0w} z{=vB6wQzJ;plGO3O+>$0s7az4!We0ohtusR)C!~la~p8gH^yj>q(pg>gPeDpahhv< zm&%|P7T%E#{W|?zKIGkwP|*!9l^_4uV4@$Y!`jPrBBl;45uT z9Pv)Di$}V^`0@1`Ruoi>7fpyckssVEL~26b11%ptbeX6h0ke7MUqK-<1Z4?st`w4f zvOl+el1ZwNv$&{Rx9Ge%-o{|!wdT9OMw@v8OIGqQ{ZM19F8fPVQdM&?JF4zN5UcG} zI_a_!pOL7M&;Hcud>TD!E85mx>?e=#Ew$6fzB5>|6$S|M-uBMaH<^y&eHX({eNEum;BUQ})gm&epqVqre20ee>zA4+O!{7)a=r##vW ztQu`D45Hu51XFHeBQXkW&bx!AloB-Zw?yp+7JZZfhrIk`Lj6EV zMFlZM*i3^}rk7{fvKoJ{LdSaJ>41!zUTX*S0|z7d8Vd}v|K@bB^N$~4+j*-&BU39e zEbrRQ%~_GZVn)C7(FCuri9baV*EWCN!&q?G;$s6|bu=HMx(&k~^b1C9)&q%#@>1!C zr2#){{mz?&G~c|!puA8_nM`iCM6P_z#>L0UFigm)#N^ujLuZ#ieF&B~k8_Cih%H#k*pFxjzD}49RjT(sJu9ROe zC`^n4{}W(a@xjxqI{8gE>qXG*D0?4 zF5!(+F>ap!W3@kHQPY^cw?XbZbN!mBL` zl(VdVk(CK-?$KP}0evZ~9)EMQwU6A?FO0YR^M zLw0r+@OD08cS`a(iYAE5^Ro^N@X5dL5a|8gkn{x#>bc*HO@IB=i&rp-9ksb9fl&DI zKBej-IBW5};#?AdS{f7tMH&J**!CHg0|wf&&1#FSfJ2QIpukgz%^Ew=u=-_-O|VtP z0Ak&B#Hs|AEi*RH^TRxIY>Tf*}v> z9+tP$(;Q1(b2CXe0Rg9r;!yuD5FOZz1v)5;YR-s?X`7ajCTGHm6*o+bEOBVpnYUMy zeSztssqfrq)Xl2V`fhUAJQ~Wx2f@EFDV;T@&vWU*8Tr#1ch+-W0;yoW2`1ypmxKdX zK8-^T3L_O(=N2}MSANxB5r(RS+ypJr$v{QvM6I-Y5#=7IfHsQ@7BoPZ5XU^5yudGw zFYcJHJPre-@2emH>eun3VMI=OC{k7vS9E{`D+HFJOdzR7)ZYIYQH-RZJSCbOBzZr8 zNzA{f1b~<6L5>VXinWI_`RUwdz(|e>fEsafc1{`#XCi{qTkYRI z!UKZn(1<1m$i<*gGvS(-kM@Hh!yY6nhA_1MLjqE_*`&x3v{Id8_=ci%#3fC2m>P&0LVnG+IVN*hO^|399aT(jv~0zZlq8=`eEVOu%TILd-gzr7JTQEH7M-&34JvjjgJ1l%0?_ zT7zo+7RQNfF8Ls1=-#dGNMlRe&L4OeVFBc5R_Sax6FaV7=iCu8PPr%cc)YWtwl0Q6 z9qNMx7FZ34?}m8i8L<^wU@s)=Km!YQ&dznU-BVUgrXH~V7}>{5YLP!FiSq}!AXeS< zbA#gSo9b)}4YobRtD8mUZTY{Z1`@vug8#>|M*HWS1$pK%zSNG|)sG@RLT&*>YFEHL zC3D%VlHy*SdMdg7EISkV19U=kbQTsyXk;)GcI?O;iaK4eFUDm>8gy@!j?ZJj^dda0 zdQa|=wShc#EF`XH*9d1?feDacj$7}$jKgkSvo2H_KD>Qaq<%J;oR(%h^YuowuyfW$%TaEM4q5`SaMe|}bF zhwX(npTe610s=zvY}W?=Wg{pISg2i3Cu3uhavZ=5#O;%(=El_*{XlQxL&Au224SPr zkn}i)D~CO3ELLK+5XCxx38KmNc((389Ksz5Gsyu^fMO&p8;h0S>~XgbL7(BB zrxuP^=a2zG0;sL8q2U4l9s4X<9QWDO7^qrv4ZC@}Z}^Z+SX>4!&QNFVFwVDHWw;xi;%*t%1%t!4YW+8SC8eOg8 zajaW=3%B)(53fgtX_@2k)iXEbmM4tLmJ(_MelC}HxA~`tvpD;dt6i1dy(1%v9KJhn z5M(K~Zn#rx>`jos87_Ms;9U4qYn@1lW%7(vg8n)XyKeT$xA<5$2_4>FYVMi}c6pDn5sPlJq zNl<6>`?#X8-aisseE7vlfr6axUf3A!XTkIErUZ|r?4Vgj2A&3wrDKcG>Fd1r=GzL1 z;f=jv)I2l2*mD-C9#+8XwZ69 zd5@gS@k2EWL-A2inB5blU#>)Xv|*~VE4q!YDa$5~MH9%t$+O z>Y)OSy;rl|$?jWMc(V@lOe@{E(IDQY6uA4Vv2UAYW_p(|7mqhHPeBNyWi_D7nK<;CJ?25Ap-VE@4IDki7$iSf~)DoaDSLoTo65*gD@8J$l9SVY9>$!838s} zVNupt7QKXgX2$0vZ`5s0i?YE(Kx>3JUF+0?c4>HS44mBcR1I^dAqpmO($}uin$xtE z`ijo>lJNfij%-}NYn(34&`RttDq;Ao~>JT4%@9n&F` zVEYQ2Y@GB_)}rLLI73aj-VN6jDb6MTc=@(Zc8-C>@VTdJd&ulQj`Qh7b#d`*T{Bci zkX$x9|65jSRJUnJ;YN*)%}D#*uk8fI-kS9*UFhS6J6L~(EtVonUNZCLzZtR4m!AXm z)eRNz_Dx|JWaX_WQY`jqab|}Yfo3Je8;P8Y(hEa1vuY;;nUh8fIF$nxbme-L`dL)p zEXmhB$Z6E$_?71GNTqrRO^qJGolDIpJR%F=b6*=15SY z1uO=&aczXf-R|kaczk#NWj?p|kH?kBgxgwGhQ)=-!Uo zEP5{qq>E!3fj|zSkLghTSzHIuRhVk$nJs(;Xe?`NoMpY&~g0@*3W?Lg&Q#$GLK7L z$#sP6n@Ka67F*zsDVJH0whP(b5xk$)MT4bqmY+OD%EUSMcOKBELk@xdfl zcF)RI+_eziOMp$$>)3$Jwb_IH%kF`TWG|ur9b1uWb%@}rlG|VGJD~FNrl8Ebk%ITB zpC~QK)MstvJZo{=TAlM55glzO! zRXRkrJCR-pdt+u^rk>mVla%O-OiHzn=W6=IPX!Wf89gMsQG zgf{hRaVz+zQhx?#gdL>CdY>jFZ5~wg-+3?uPOonU@HDs(3k*_1;^MO>T^JM=xK32X zHde42_Poinx?uj{<2j^J{d<;^x%`z-l7$X0dagz70FBMWoHdb2Y?T6!hL!|Uv+S-H zcZK^9iMu7}!t1rDE($jB<6AUU7 z1j&@?6^M)uNXEHfS{{12<}S66JU#LA=#_CW^!_f;4*3Ti8Qn%h6Yl$>4Na{!37_To z(v~dSA~8R{aaVh*AMjhk2R$Vs-XBD(FQ@Zd{3I@i^+!KN1otmyFC4A8_i!keJ&^9q zC)sR-{cs$-l|TWzTfr!k`Q=sEX_fM$Eosy9tv*#ZX)Yo7`jh^C62H3KXHFDjkHr1Y zdyDk?Cum=@^lYm|>fX~fqAA7OR)3-p##iVxHuo7lI?j8y)CA=IID2}pA%0EPMZZ(N zfj2+zz|guOCW{#>So?c5LPuwn+HswdnQ6_6`y#Tq@0vAH+U7=OeZzX<@AMu#Rrxau z*Do_S?a#{l+msUv*MnR5KV06MM-$BEUpOy(U@%itdk`x0D{>qgwLTZBfd@XZrH#T5 zt+ypdrqSO%Wv3#~_QzXK&U{|cNvJ@K1lvyEjRy*T;`>F?LV*!UORSRK%NhBPSX|E3 z16jvd>`{H3xBnO5DTbgqPS)Swf(tWoj9wObyt)&13v^nb!U-d@KIu*&Q2i9x$1^NQ z+d(3@t)mdl7bv|`PvXYlqod=mwwTh8z#Y~1TExJ^&SdL`lZT|i1D{Mt0s?(@t=7=r z9L47IVGk19_7IV2t4|xM7b1#G-DiCFyMVqumKX-XePn+ z1ux^Jhy~c*vs3g}9R+zh0OxzO3+zmreY5DDjRRF#9gxnF`7YGf23ZxHV6oBK_Bf&0 z46`2XB`5BcKtk`y>)EX>Ea0Agn_)}!WB@eX%7V}1Q*Y)1=Gip3Ay$JtHuVbY>Vn4; zsRCz)LbhlySp2s?+}NUE*G@1mA?5p<>v?F{gpEF`)yY=3WRs3N;w zdG2xodmz>B#*Xn@^S4BL<#|?s#j{?Y7+??3PATeDH8v-!OWS?z}=>x&4KL} zMPt8qe=_z{V@lL_zIIXQJ^+YZBJ`dy6`=k(M@dQVcmZc-WL!_1W3{%DujSI8 zw{T+|O0s^8@t)G$o^`p0_GyJkj(~8#cFmPrIQGQhr^z$(xmX+D=Q7{O=K8IX^taKW zHn?szpR-!s=lxS7I>LOJ9vH|{ha+a;Pj6WJq+E9|H(_*HypE$UC>qS^G@Gd&J9;B{ zX!ibyvLWkfSG#O$X;hbl43AE_#fE7~g($ck_^Vrp{d#nQS(%mDX55oC+)|`W!t=BO#Nb&gxydxZV z1c-=|GmxLGq+>wKyi!|LR_lRu&MQoF_m~{r<`!vX@&~WRn@X_$iA-5e+k8F}9v~(< z3^%@=sZfgQI|HE)+SSc6a^iOI4noY09EgVg%NgVNRoF!P)9Y_KyEnQoGTj-8!LM*) z5;AavlKLjxIrFOoUVDd=%}359;wZsX26zWgg7}W^FoNc{(Ru1Y30MCU`+>fz}0JIcdhRA z8=@8W{#nT8%iSf5hi4(5&5uGG4XyR$MNq`ov2>KjsZHZ0uen3Xg4epo`H*@T4PDFX zJWB^>+eM0zl>3EhXmROdA*d8c7Q0i0lgYE33 z_+mu%31L3%ygaD9HejmTNkk~u_jld<+JcU5EPzxaEPybxxsMic)wJU`AFK1K#f2At z$a|{{_Bn^NTN~le+bpz8i>lWVV`km;=D_<#hGGhww{e z-3AswW=>7*DlvoaDe1CgzeX*^%t<7qr@HYj7(6ml=R1qy1-aADpvmI+x7*|J)9V0> zaNzr1X35JG_Mwm9?&aybyH#i9`8q4fffZD*RjHNP3^LO`zw}Jttk$QSmURK*%|$Ix zqVet*?=B0OZilg4KKoT9@WuC+a?bu;#UR1)6i~(FvTZ}BGqC5 zR`DKN%Ku+f`j2wqQ#fR5blpB+MBwN?M9sm``0ss#dWVAaUX1pa{V61bS!~xFpBehy^El7JOJz_JtuPuy}Yd_^XYXw!&bd7S@z` zO>b(YmR0OChDG>5Y-2_4Ew~W}2Wyy?qM%TNj$_RdDZEUFHn4wQibJ5RCofg_G?EXY zl~k8Z9Vtzv^DhDx#$a-y(4#??SX3+ezHhN|Rv!--mAFujWW0RlEgW$6NaI zX{I#}#w|tVGT`ZSW5!O`D@Fbb`@@wCS9ak_!51d(of9SzQR4uKAqoj2@k&d)BPn3a zNNUMZQ)E>+{N1S%am^1f)mTdnehafYi$*YYf0f%|@zjmj!_iMc3r-{=4C;v*k-VV~ zclKkW4kAn`c{OMv^jv&qxiF>8FFc5BIN|1gWiLJ{K?TUie1hy3@IyQ%Nu1cg~1!FRijGD-!108 zjuIW(^EOoW9%AWt+u$?m(@HX(i|DW(KJmS95lyWXv-p4YMZ=0ZDEwOqg(YX`@HnZR zzANZyzI#2YoIQRtc|}6+zZCHEq*QG`noMAI?cyq4i9$Lnn-YsA<(y{?SlUs0Jb^;Z zN zJzbd@dE3)1aUSnJ__bbT2aMF^tnz9}6bmif$=dExvI!}=YMITQJGvgFm$CRBSqZ^*74WO!ZBX;Efaw7W z`;miiIAZu5^&u$JFRkSjsOm<;uxv-)xE~!bd&!2eM35s1ercX0{Hj!$Z{M zAC=m)-3J+1C<(nA+YgSiVgfhX&7-kgzNR0TKTnzh)!p{S2VSCx(K;Y=5H>u-OEr`eH*11gmG#YhULm+e6U|! zvhh*3a`MQ6Y=jPDd6d>ZtDD4xe~5dI@H3?}Lt7T;qp|`<>3o#cQ~4aWryj;6D=TYg zctLZ35Ee3-kT8;qgzfzaId(o|P*9)`a(bake3Nx-$-yO0tKmx@(y7~`BTL&R_~tQ~ zuEGn_F3deruVuW=cm8n;)CfoM8@dav9%z^z1@!N55lW2z*Z%%U0ssKs-)&iekl;Mv e!#{uw1Gwzcl{oi|fdnK009i>Ti5fAJkpBf<4>eu@ literal 0 HcmV?d00001 diff --git a/lib/wireguard-response.ts b/lib/wireguard-response.ts new file mode 100644 index 00000000..8e4b21a4 --- /dev/null +++ b/lib/wireguard-response.ts @@ -0,0 +1,6 @@ +import { z } from "zod" +import { serverReadSchema } from "@mmapp/contracts/servers" +const server = serverReadSchema.pick({id:true,name:true,host:true,country:true,type:true,enabled:true,asn:true,status:true,latency:true}) + .partial({type:true,asn:true,status:true,latency:true}) + .extend({id:z.number().int().positive().max(Number.MAX_SAFE_INTEGER),host:z.string().trim().min(1),latency:z.number().finite().nonnegative().nullable().optional()}) +export const wireGuardServersSchema = z.array(server).refine(rows => new Set(rows.map(row => row.id)).size === rows.length) diff --git a/playwright.config.ts b/playwright.config.ts index 903205a6..7d545654 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/uptime-context.spec.ts", "**/alerts-context.spec.ts", "**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], + testIgnore: ["**/wireguard-context.spec.ts", "**/uptime-context.spec.ts", "**/alerts-context.spec.ts", "**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], fullyParallel: false, workers: 1, retries: 0, diff --git a/shared/api/wireguard.ts b/shared/api/wireguard.ts index 0f93ad40..1e6b5337 100644 --- a/shared/api/wireguard.ts +++ b/shared/api/wireguard.ts @@ -1,5 +1,5 @@ import { parseConfigRevisions } from "@/lib/config-revisions" -import { wgIfaceDtoSchema, wgImportResponseSchema, wgExportResponseSchema } from "@mmapp/contracts/wireguard" +import { wgListResponseSchema, wgIfaceDtoSchema, wgImportResponseSchema, wgExportResponseSchema } from "@mmapp/contracts/wireguard" import type { WgCreateInterface, WgCreatePeerRequest, @@ -21,7 +21,14 @@ export async function listWireGuard( if (opts?.serverId) q.set("serverId", opts.serverId) if (opts?.includePrivateKey) q.set("includePrivateKey", "1") const qs = q.toString() - return requestJson(baseUrl, `/api/wireguard${qs ? `?${qs}` : ""}`) + const parsed = wgListResponseSchema.safeParse(await requestJson(baseUrl, `/api/wireguard${qs ? `?${qs}` : ""}`)) + if (!parsed.success) throw new Error("Ответ сервера содержит некорректный каталог WireGuard") + const catalog = parsed.data, failures = catalog.failures ?? [] + const uniqueInterfaces = new Set(catalog.interfaces.map(row => JSON.stringify([row.serverId, row.id]))).size === catalog.interfaces.length + const uniquePeers = catalog.interfaces.every(row => new Set(row.peers.map(peer => peer.id)).size === row.peers.length) + const consistentFailures = failures.every(f => f.serverId.trim().length > 0) && new Set(failures.map(f => f.serverId)).size === failures.length && !catalog.interfaces.some(row => failures.some(f => f.serverId === row.serverId)) + if (!uniqueInterfaces || !uniquePeers || !consistentFailures) throw new Error("Ответ сервера содержит некорректный каталог WireGuard") + return catalog } export async function createWireGuardInterface( diff --git a/tests/e2e/large-wireguard-peers.spec.ts b/tests/e2e/large-wireguard-peers.spec.ts index fb6426ab..486d3d92 100644 --- a/tests/e2e/large-wireguard-peers.spec.ts +++ b/tests/e2e/large-wireguard-peers.spec.ts @@ -69,7 +69,7 @@ test("WireGuard1000 peer details: bounded DOM/page/refresh/clamp/keyboard/320px" for(let i=1;i<19;i++)await next.click() await expect(next).toBeDisabled();await expect(items.first()).toHaveAttribute("data-wireguard-peer","p951") const before=calls;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect.poll(()=>calls).toBeGreaterThan(before);await expect(items.first()).toHaveAttribute("data-wireguard-peer","p951") - await page.setViewportSize({width:320,height:1000});await region.scrollIntoViewIfNeeded() + await page.setViewportSize({width:320,height:1000});await items.first().scrollIntoViewIfNeeded() const scroll=page.locator('[data-slot="data-grid"] [data-slot="scroll-area-viewport"]');await scroll.evaluate(el=>{el.scrollLeft=el.scrollWidth}) expect(await region.evaluate(el=>{const r=el.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth})).toBe(true) await expect(items.first().getByRole("button",{name:"Удалить пира",exact:true})).toBeInViewport() diff --git a/tests/e2e/wireguard-context.spec.ts b/tests/e2e/wireguard-context.spec.ts new file mode 100644 index 00000000..758823b1 --- /dev/null +++ b/tests/e2e/wireguard-context.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from "@playwright/test" +const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,PATCH,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"} +const iface=(port:string)=>({id:"*1",rosId:"*1",name:port==="8101"?"Новый WireGuard":"Старый WireGuard",serverId:"1",serverName:"Роутер",listenPort:13231,mtu:1420,comment:"",enabled:true,status:"up",peers:[]}) +for(const operation of ["read","toggle"])for(const change of ["backend","mode"])for(const fails of [false,true])test("WireGuard source context/"+operation+"/"+change+"/"+fails,async({page})=>{ + let oldReads=0 + await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}})) + await page.route("**/api/**",r=>{const url=new URL(r.request().url()),p=url.pathname;if(p==="/api/wireguard"&&url.port==="8000")oldReads++;return r.fulfill({headers,json:p==="/api/wireguard"?{interfaces:[iface(url.port)]}:p==="/api/servers"?[{id:1,name:"Роутер",host:"192.0.2.1",country:"RU",enabled:true,status:null}]:p==="/api/auth/config"?{required:false}:{}})}) + await page.goto("/settings");await page.getByRole("link",{name:/^WireGuard/}).click() + await expect(page.locator("tr[data-row-id]")).toContainText("Старый WireGuard") + let release:(()=>void)|undefined,started=false + const gate=new Promise(resolve=>{release=resolve}) + await page.route(operation==="read"?"**/api/wireguard":"**/api/wireguard/interfaces/**",async r=>{ + const url=new URL(r.request().url());if(url.port!=="8000"||(operation==="toggle"&&r.request().method()!=="PATCH"))return r.fallback() + started=true;await gate + return r.fulfill(fails?{headers,status:503,json:{error:"Старая ошибка WireGuard"}}:{headers,json:operation==="read"?{interfaces:[]}:{ok:true}}) + }) + if(operation==="read")await page.getByRole("button",{name:"Обновить",exact:true}).click() + else{await page.getByRole("button",{name:"Действия: Старый WireGuard",exact:true}).click();await page.getByRole("menuitem",{name:"Отключить",exact:true}).click()} + await expect.poll(()=>started).toBe(true) + await page.goBack();await expect(page).toHaveURL(/settings/) + if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")} + else await page.getByRole("button",{name:"Моковые",exact:true}).click() + await page.getByRole("link",{name:/^WireGuard/}).click() + if(change==="backend")await expect(page.locator("tr[data-row-id]")).toContainText("Новый WireGuard") + else await expect(page.getByText("Mock-режим",{exact:true})).toBeVisible() + const before=oldReads,response=page.waitForResponse(r=>r.url().includes(":8000/api/wireguard")&&(operation==="read"||r.request().method()==="PATCH")) + release!();await(await response).finished();await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())))) + if(change==="backend")await expect(page.locator("tr[data-row-id]")).toContainText("Новый WireGuard") + await expect(page.getByText("Старая ошибка WireGuard",{exact:true})).toHaveCount(0) + await expect(page.getByText("Отключено",{exact:true})).toHaveCount(0) + expect(oldReads).toBe(before) +}) diff --git a/tests/e2e/wireguard-links.spec.ts b/tests/e2e/wireguard-links.spec.ts index 6cd2c9f1..427b9c77 100644 --- a/tests/e2e/wireguard-links.spec.ts +++ b/tests/e2e/wireguard-links.spec.ts @@ -553,3 +553,53 @@ for(const invalid of ["tab=bad","status=bad","q=a&q=b","tab=peers&tab=cli"])test await expect(page).toHaveURL(url=>url.searchParams.get("server")==="2"&&url.searchParams.get("interface")==="*/2"&&url.searchParams.get("keep")==="1") expect(control.posts).toEqual([]) }) + +test("WireGuard reading: unknown initial/catalog DTO/cache freshness and guarded writes",async({page},info)=>{ + const control=await setup(page,"/wireguard?server=1&interface=*%2F2",503) + const read=page.getByRole("alert",{name:"Чтение WireGuard",exact:true}) + await expect(read).toContainText("Каталог пока неизвестен") + await expect(page.getByText("Нет WireGuard интерфейсов",{exact:true})).toHaveCount(0) + await expect(page.getByLabel("Сводка WireGuard").getByText("—",{exact:true})).toHaveCount(4) + await expect(page.getByRole("button",{name:"Новый интерфейс",exact:true}).first()).toBeDisabled() + control.failed=0;await page.getByRole("button",{name:"Обновить",exact:true}).click() + const rows=page.locator("tr[data-row-id]");await expect(rows).toHaveCount(1) + await expect(page.getByRole("status",{name:"Чтение WireGuard",exact:true})).toContainText("Получены") + await page.route("**/api/wireguard",r=>r.fulfill({json:{interfaces:[{id:"broken"}]}})) + await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(read).toContainText("Показаны последние полученные данные") + await expect(rows).toHaveCount(1);await rows.getByRole("button",{name:"Действия: wg-same",exact:true}).click() + await expect(page.getByRole("menuitem",{name:"Удалить",exact:true})).toHaveCount(0);await page.keyboard.press("Escape") + await page.setViewportSize({width:320,height:1000});await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(320) + await page.addStyleTag({content:"nextjs-portal{display:none!important}"});await page.screenshot({path:info.outputPath("wireguard-read-error-320.png"),animations:"disabled"}) + expect(control.posts).toEqual([]) +}) + +test("WireGuard partial device: failed source never becomes confirmed empty KPI",async({page})=>{ + const control=await setup(page,"/wireguard?server=2&interface=*%2F2") + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + control.partial=true;await page.getByRole("button",{name:"Обновить",exact:true}).click() + await expect(page.getByRole("status",{name:"WireGuard по ссылке",exact:true})).toContainText("Не удалось получить данные выбранного сервера") + await expect(page.getByLabel("Сводка WireGuard").getByText("—",{exact:true})).toHaveCount(4) + await expect(page.getByText("Нет WireGuard интерфейсов",{exact:true})).toHaveCount(0) + await expect(page.locator("tr[data-row-id]")).toHaveCount(0);expect(control.posts).toEqual([]) +}) + +for(const kind of ["interfaces","peers","overlap","failures"])test("WireGuard invalid identity/failures cannot replace cache/"+kind,async({page})=>{ + const control=await setup(page,"/wireguard?server=1&interface=*%2F2") + const rows=page.locator("tr[data-row-id]");await expect(rows).toHaveCount(1) + const peer={id:"p",rosId:"*P",publicKey:"key",allowedIps:[]},iface={id:"*/2",rosId:"*/2",name:"Invalid replacement",serverId:"1",serverName:"Источник",listenPort:13231,mtu:1420,comment:"",enabled:true,status:"up",peers:kind==="peers"?[peer,peer]:[peer]} + await page.route("**/api/wireguard",r=>r.fulfill({json:{interfaces:kind==="interfaces"?[iface,iface]:[iface],failures:kind==="overlap"?[{serverId:"1",error:"failed"}]:kind==="failures"?[{serverId:"2",error:"failed"},{serverId:"2",error:"failed"}]:[]}})) + await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByRole("alert",{name:"Чтение WireGuard",exact:true})).toContainText("Показаны последние полученные данные") + await expect(rows).toHaveCount(1);await expect(rows.first()).toContainText("wg-same");await expect(page.getByText("Invalid replacement",{exact:true})).toHaveCount(0) + expect(control.posts).toEqual([]) +}) + +test("WireGuard malformed device catalog cannot publish replacement or freshness",async({page})=>{ + const control=await setup(page,"/wireguard?server=1&interface=*%2F2") + const rows=page.locator("tr[data-row-id]");await expect(rows).toHaveCount(1) + await page.route("**/api/servers",r=>r.fulfill({json:[{id:1}]})) + await page.route("**/api/wireguard",r=>r.fulfill({json:{interfaces:[]}})) + await page.getByRole("button",{name:"Обновить",exact:true}).click() + await expect(page.getByRole("alert",{name:"Чтение WireGuard",exact:true})).toContainText("некорректный каталог устройств") + await expect(rows).toHaveCount(1);await expect(rows.first()).toContainText("wg-same") + expect(control.posts).toEqual([]) +})