fix(ui): защищать чтение WireGuard при смене источника

This commit is contained in:
Denozordec committed 2026-10-04 12:48:24 +07:00
1 parent e51964b4d2
commit c7aec1500c
11 files changed
+182 -53

No files matched your search

+65 -47
View File
@@ -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<WgFailure[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState(false)
const [readError, setReadError] = useState<string | null>(null)
const [receivedAt, setReceivedAt] = useState<number | null>(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<string | null>(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<BackendServer[]>(backendUrl, "/api/servers"),
requestJson<unknown>(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 = (
<Button size="sm" disabled={!!urlError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<Button size="sm" disabled={!!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
@@ -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={
<PageHeader
@@ -699,11 +716,11 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
</Button>
</>
)}
<Button size="sm" variant="outline" disabled={!!urlError} onClick={() => { setImportError(null); setImportOpen(true) }}>
<Button size="sm" variant="outline" disabled={!!urlError || !catalogKnown || loadError} onClick={() => { setImportError(null); setImportOpen(true) }}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" disabled={!!urlError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<Button size="sm" disabled={!!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
@@ -714,7 +731,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
>
<div className="flex flex-col gap-4">
{urlError && <Alert variant="warning" role="alert" aria-label="Фильтры WireGuard"><AlertTitle>Проверьте ссылку</AlertTitle><AlertDescription><p>{urlError}</p><Button type="button" variant="outline" onClick={resetFilters}>Сбросить фильтры</Button></AlertDescription></Alert>}
{isLive && loadError && selectedInterfaceId === null && <Alert variant="warning"><AlertTitle>Не удалось обновить WireGuard</AlertTitle><AlertDescription>Показаны последние полученные данные. Повторите обновление перед изменением объектов.</AlertDescription></Alert>}
{isLive && <Alert variant={loadError ? "warning" : "info"} role={loadError ? "alert" : "status"} aria-label="Чтение WireGuard"><AlertTitle>{loadError ? "Не удалось обновить WireGuard" : loading ? "Обновление WireGuard" : "Данные WireGuard"}</AlertTitle><AlertDescription>{loadError && readError && <p className="wrap-anywhere">{readError}</p>}{receivedAt !== null ? <p>Получены {new Date(receivedAt).toLocaleTimeString("ru-RU")}.{loadError ? " Показаны последние полученные данные. Повторите обновление перед изменением объектов." : loading ? " Выполняется обновление." : ""}</p> : <p>{loadError ? "Каталог пока неизвестен. Повторите обновление." : "Ожидание ответа сервера."}</p>}</AlertDescription></Alert>}
{selectedInterfaceId !== null && (
<Alert variant={selectedSourceError || ((!isLive || !loading) && !selectedInterface) ? "warning" : "info"} role="status" aria-label="WireGuard по ссылке">
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedInterface ? "Выбран интерфейс: " + selectedInterface.name : "WireGuard по ссылке"}</AlertTitle>
@@ -735,28 +752,28 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
{
id: "ifaces",
label: "Интерфейсов",
value: scopedIfaces.length,
value: completeScope ? scopedIfaces.length : "—",
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных (UP)",
value: upIfaces,
value: completeScope ? upIfaces : "—",
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "peers",
label: "Всего пиров",
value: totalPeers,
value: completeScope ? totalPeers : "—",
icon: <UsersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "handshake-data",
label: "Данные рукопожатия",
value: `${peersWithHandshakeData}/${totalPeers}`,
value: completeScope ? `${peersWithHandshakeData}/${totalPeers}` : "—",
hint: <span className="block text-xs text-muted-foreground">Доступность пиров не проверена</span>,
icon: <KeyRoundIcon className="size-4" />,
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
],
}}
/>
<WireguardDataGrid
{catalogKnown && !urlError && (completeScope || filteredIfaces.length > 0) ? <WireguardDataGrid
interfaces={filteredIfaces}
compactServer={compactServer}
emptyAction={emptyCreateAction}
@@ -832,7 +849,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
onDelete={selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })}
onDeletePeer={selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
/>
/> : <p role="status" className="px-5 py-8 text-sm text-muted-foreground">Каталог интерфейсов пока неизвестен</p>}
</DataPageCard>
</TabsContent>
@@ -842,9 +859,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по ключу, IP, endpoint…"
countLabel={`${peerRows.length} пиров`}
countLabel={catalogKnown && !urlError ? `${peerRows.length} пиров` : "Количество неизвестно"}
/>
<WireguardPeersGrid
{catalogKnown && !urlError && (completeScope || peerRows.length > 0) ? <WireguardPeersGrid
peers={peerRows}
compactServer={compactServer}
emptyAction={
@@ -865,7 +882,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
if (!iface) return
openExport(iface, "peer", row.id)
}}
/>
/> : <p role="status" className="px-5 py-8 text-sm text-muted-foreground">Каталог пиров пока неизвестен</p>}
</DataPageCard>
</TabsContent>
@@ -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 <WireGuardPageContent key={JSON.stringify([serverId, interfaceId])} selectedServerId={serverId} selectedInterfaceId={interfaceId} allInterfacesHref={allInterfacesHref} onSelectServer={id => {
return <WireGuardPageContent key={JSON.stringify([serverId, interfaceId, mode, backendUrl])} selectedServerId={serverId} selectedInterfaceId={interfaceId} allInterfacesHref={allInterfacesHref} onSelectServer={id => {
const next = new URLSearchParams(rest)
if (id === ALL_SERVERS_ID) next.delete("server")
else next.set("server", id)
+1 -1
View File
@@ -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 принLine truncated
Этап 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 принLine truncated
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+6 -1
View File
@@ -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)
+11
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+6
View File
@@ -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)
+1 -1
View File
@@ -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,
+9 -2
View File
@@ -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<WgListResponse>(baseUrl, `/api/wireguard${qs ? `?${qs}` : ""}`)
const parsed = wgListResponseSchema.safeParse(await requestJson<unknown>(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(
+1 -1
View File
@@ -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()
+32
View File
@@ -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<void>(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<void>(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)
})
+50
View File
@@ -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([])
})