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 00000000..bd467755 Binary files /dev/null and b/docs/ux-ui/screens/wireguard-read-error-320.png differ 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([]) +})