fix(ui): сохранять контекст и блокировку рекурсивных маршрутов
This commit is contained in:
1 parent
52493debc2
commit
50fad3ac80
10 files changed
+223
-28
No files matched your search
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useMemo, useState, Suspense } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { readRecursiveUrl, patchRecursiveUrl, type RecursiveUrlPatch, type RecursiveUrlState } from "@/lib/recursive-route-url-state"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
RecursiveRoutesDataGrid,
|
||||
@@ -358,25 +360,51 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
|
||||
}
|
||||
|
||||
export default function RecursiveRoutesPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
return <RecursiveRoutesContent key={JSON.stringify([mode, backendUrl])} mode={mode} backendUrl={backendUrl} />
|
||||
return <Suspense fallback={<p role="status" className="p-6 text-sm text-muted-foreground">Загрузка маршрутов…</p>}><RecursiveRoutesRoute /></Suspense>
|
||||
}
|
||||
|
||||
function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl: string }) {
|
||||
function RecursiveRoutesRoute() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const params = useSearchParams(), context = readRecursiveUrl(new URLSearchParams(params.toString()))
|
||||
const sourceKey = JSON.stringify([mode, backendUrl])
|
||||
const pendingSources = useRef(new Set<string>())
|
||||
const [pending, setPending] = useState<string[]>([])
|
||||
const [readVersions, setReadVersions] = useState<Record<string, number>>({})
|
||||
const beginWrite = useCallback(() => {
|
||||
if (pendingSources.current.has(sourceKey)) return false
|
||||
pendingSources.current.add(sourceKey); setPending([...pendingSources.current]); return true
|
||||
}, [sourceKey])
|
||||
const endWrite = useCallback(() => {
|
||||
pendingSources.current.delete(sourceKey); setPending([...pendingSources.current])
|
||||
setReadVersions(versions => ({ ...versions, [sourceKey]: (versions[sourceKey] ?? 0) + 1 }))
|
||||
}, [sourceKey])
|
||||
const patchUrl = useCallback((patch: RecursiveUrlPatch, replace = false) => {
|
||||
const url = new URL(window.location.href); url.search = patchRecursiveUrl(url.searchParams, patch).toString()
|
||||
const target = url.pathname + url.search + url.hash
|
||||
if (replace) window.history.replaceState(null, "", target); else window.history.pushState(null, "", target)
|
||||
}, [])
|
||||
return <RecursiveRoutesContent key={JSON.stringify([mode, backendUrl, context.serverId, context.error])} mode={mode} backendUrl={backendUrl} context={context} patchUrl={patchUrl} beginWrite={beginWrite} endWrite={endWrite} sharedBusy={pending.includes(sourceKey)} readVersion={readVersions[sourceKey] ?? 0} />
|
||||
}
|
||||
|
||||
function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrite, endWrite, sharedBusy, readVersion }: {
|
||||
beginWrite: () => boolean; endWrite: () => void; sharedBusy: boolean; readVersion: number
|
||||
mode: string; backendUrl: string; context: RecursiveUrlState; patchUrl: (patch: RecursiveUrlPatch, replace?: boolean) => void
|
||||
}) {
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [servers, setServers] = useState<Array<Omit<Server, "status"> & { status: Server["status"] | null }>>([])
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>("")
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>(context.serverId ?? "")
|
||||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const search = context.q
|
||||
const setSearch = (q: string) => patchUrl({ q }, true)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
|
||||
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
|
||||
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
||||
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
|
||||
const expandedGroupKey = context.route && context.table ? JSON.stringify([context.route.toLowerCase(), context.table]) : null
|
||||
const [opError, setOpError] = useState<string | null>(null)
|
||||
const [liveStale, setLiveStale] = useState(true)
|
||||
const [routeOwner, setRouteOwner] = useState("")
|
||||
@@ -418,7 +446,8 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
if (!parsed.success) throw new Error("Некорректный список устройств в ответе API")
|
||||
const mapped = parsed.data.map(s => mapBackendServer({ ...s, type: s.type ?? "exit-node", site: s.site ?? "", asn: s.asn ?? "", status: s.status ?? null, latency: s.latency ?? null }))
|
||||
setServers(mapped)
|
||||
setSelectedServerId(prev => (mapped.some(s => s.id === prev) ? prev : (mapped[0]?.id ?? "")))
|
||||
setSelectedServerId(context.serverId ?? mapped[0]?.id ?? "")
|
||||
if (!context.serverId && !context.error && mapped[0]) patchUrl({ serverId: mapped[0].id }, true)
|
||||
})
|
||||
.catch(error => {
|
||||
if (!active) return
|
||||
@@ -430,10 +459,10 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
if (active) setLiveServerListReady(true)
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [isLive, apiFetch, inventoryTick])
|
||||
}, [isLive, apiFetch, inventoryTick, context.serverId, context.error, patchUrl])
|
||||
|
||||
const loadRoutes = useCallback(async () => {
|
||||
if (!isLive || !liveServerListReady || !selectedServerId || writeLock.current) return
|
||||
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId) || writeLock.current) return
|
||||
const epoch = ++readEpoch.current
|
||||
setOpError(null); setBusy("load"); setLiveStale(true)
|
||||
try {
|
||||
@@ -451,10 +480,10 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
} finally {
|
||||
if (alive.current && epoch === readEpoch.current) setBusy(null)
|
||||
}
|
||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
|
||||
|
||||
const loadGateways = useCallback(async () => {
|
||||
if (!isLive || !liveServerListReady || !selectedServerId) return
|
||||
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId)) return
|
||||
const epoch = ++gatewayEpoch.current
|
||||
try {
|
||||
const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways?serverId=${encodeURIComponent(selectedServerId)}`))
|
||||
@@ -467,19 +496,19 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
if (!alive.current || epoch !== gatewayEpoch.current) return
|
||||
setGatewayOptions([]); setGatewayError(error instanceof Error ? error.message : "Не удалось загрузить шлюзы")
|
||||
}
|
||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadRoutes(); void loadGateways()
|
||||
const reads = readEpoch, gateways = gatewayEpoch
|
||||
return () => { reads.current++; gateways.current++ }
|
||||
}, [loadRoutes, loadGateways])
|
||||
}, [loadRoutes, loadGateways, readVersion])
|
||||
|
||||
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
|
||||
if (!isLive || !selectedServerId || liveStale || routeOwner !== selectedServerId || busy !== null || writeLock.current || !can("mm:network:write")) return false
|
||||
if (!isLive || !selectedServerId || liveStale || routeOwner !== selectedServerId || busy !== null || sharedBusy || writeLock.current || !can("mm:network:write")) return false
|
||||
if (!beginWrite()) return false
|
||||
writeLock.current = true; readEpoch.current++
|
||||
let failed = false
|
||||
setOpError(null); setSaveError(null); setBusy("apply")
|
||||
try {
|
||||
const raw = await apiFetch<unknown>("/api/recursive-routes", { method: "PUT", body: JSON.stringify({ serverId: selectedServerId, routes: next }) })
|
||||
@@ -492,14 +521,14 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
} catch (error) {
|
||||
if (!alive.current) return false
|
||||
const message = error instanceof Error ? error.message : "Не удалось применить маршруты на роутер"
|
||||
failed = true
|
||||
setOpError(message); setSaveError(message); setLiveStale(true); toast.error(message)
|
||||
return false
|
||||
} finally {
|
||||
writeLock.current = false
|
||||
if (alive.current) { setBusy(null); if (failed) void loadRoutes() }
|
||||
endWrite()
|
||||
if (alive.current) setBusy(null)
|
||||
}
|
||||
}, [isLive, selectedServerId, routeOwner, liveStale, busy, apiFetch, loadRoutes])
|
||||
}, [isLive, selectedServerId, routeOwner, liveStale, busy, sharedBusy, apiFetch, beginWrite, endWrite])
|
||||
|
||||
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
|
||||
|
||||
@@ -523,7 +552,8 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
}, [isLive, selectedServerId, apiFetch])
|
||||
|
||||
const restoreRevision = useCallback(async (id: string) => {
|
||||
if (!isLive || !selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || writeLock.current || busy !== null || !can("mm:network:write")) throw new Error("Восстановление сейчас недоступно")
|
||||
if (!isLive || !selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || writeLock.current || busy !== null || sharedBusy || !can("mm:network:write")) throw new Error("Восстановление сейчас недоступно")
|
||||
if (!beginWrite()) throw new Error("Другая операция ещё выполняется")
|
||||
writeLock.current = true; readEpoch.current++
|
||||
setHistoryRestoring(true); setBusy("apply")
|
||||
try {
|
||||
@@ -538,9 +568,10 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
throw error
|
||||
} finally {
|
||||
writeLock.current = false
|
||||
endWrite()
|
||||
if (alive.current) { setHistoryRestoring(false); setBusy(null) }
|
||||
}
|
||||
}, [isLive, selectedServerId, servers, busy, apiFetch, loadRevisions])
|
||||
}, [isLive, selectedServerId, servers, busy, sharedBusy, apiFetch, loadRevisions, beginWrite, endWrite])
|
||||
|
||||
function groupKeyOf(row: RecursiveRouteRow): string {
|
||||
return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()])
|
||||
@@ -607,9 +638,10 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
return saved
|
||||
}
|
||||
|
||||
const contextError = context.error ?? (isLive && liveServerListReady && context.serverId && !servers.some(s => s.id === context.serverId) ? "Устройство из ссылки недоступно" : null)
|
||||
const hasRouteSnapshot = routeOwner === selectedServerId && (!liveStale || rows.length > 0)
|
||||
const currentServer = servers.find(s => s.id === selectedServerId)
|
||||
const mutationsLocked = !isLive || busy !== null || liveStale || routeOwner !== selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || !can("mm:network:write")
|
||||
const mutationsLocked = Boolean(contextError) || !isLive || busy !== null || sharedBusy || liveStale || routeOwner !== selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || !can("mm:network:write")
|
||||
const rrRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
servers.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -658,13 +690,14 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
items={rrRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={id => {
|
||||
if (writeLock.current || id === selectedServerId) return
|
||||
if (writeLock.current || sharedBusy || id === selectedServerId) return
|
||||
readEpoch.current++; gatewayEpoch.current++; historyEpoch.current++
|
||||
setSelectedServerId(id); setRows([]); setRouteOwner(""); setLiveStale(true); setBusy(null)
|
||||
setGatewayOptions([]); setGatewayError(null); setOpError(null); setSaveError(null)
|
||||
setReadAt(null); setWriteAt(null); setSheetOpen(false); setHistoryOpen(false); setRevisions([]); setHistoryError(null)
|
||||
setExpandedGroupKey(null)
|
||||
patchUrl({ serverId: id, route: null, table: null })
|
||||
}}
|
||||
preserveSelection={true}
|
||||
showAll={false}
|
||||
showCount={false}
|
||||
loading={isLive && !liveServerListReady}
|
||||
@@ -727,6 +760,9 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
<p className="text-xs text-muted-foreground ml-auto">
|
||||
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||
</p>
|
||||
{sharedBusy && busy !== "apply" && <p role="status" className="w-full text-sm text-muted-foreground">Операция предыдущего контекста ещё выполняется. Изменения заблокированы до ответа API.</p>}
|
||||
{contextError && <p role="alert" className="w-full text-sm text-warning">{contextError}. Выберите устройство или сбросьте ссылку. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ serverId: null, q: null, route: null, table: null })}>Сбросить контекст маршрутов</Button></p>}
|
||||
{expandedGroupKey && !liveStale && !groupedRoutes.some(group => group.key === expandedGroupKey) && <p role="status" className="w-full text-sm text-muted-foreground">Маршрут из ссылки отсутствует в текущем срезе. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ route: null, table: null })}>Закрыть маршрут</Button></p>}
|
||||
{(opError || saveError) && (
|
||||
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2" role="alert">
|
||||
{opError || saveError}
|
||||
@@ -776,7 +812,10 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
|
||||
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
||||
expandedKey={expandedGroupKey}
|
||||
onExpandedChange={setExpandedGroupKey}
|
||||
onExpandedChange={key => {
|
||||
const group = groupedRoutes.find(group => group.key === key)
|
||||
patchUrl({ route: group?.dstAddress ?? null, table: group?.routingTable ?? null })
|
||||
}}
|
||||
mutationsDisabled={mutationsLocked}
|
||||
onEdit={openEdit}
|
||||
onDelete={(g) => {
|
||||
@@ -813,7 +852,7 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
||||
itemLabel="маршрутов"
|
||||
revisions={revisions}
|
||||
loading={historyLoading}
|
||||
restoreDisabled={!can("mm:network:write") || !currentServer?.enabled || busy !== null && !historyRestoring}
|
||||
restoreDisabled={!can("mm:network:write") || !currentServer?.enabled || (busy !== null || sharedBusy) && !historyRestoring}
|
||||
restoring={historyRestoring}
|
||||
onRestore={restoreRevision}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user