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)