fix(ui): защищать чтение WireGuard при смене источника
This commit is contained in:
1 parent
e51964b4d2
commit
c7aec1500c
11 files changed
+182
-53
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user