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"
|
"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 { PageHeader } from "@/components/page-header"
|
||||||
import {
|
import {
|
||||||
RecursiveRoutesDataGrid,
|
RecursiveRoutesDataGrid,
|
||||||
@@ -358,25 +360,51 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RecursiveRoutesPage() {
|
export default function RecursiveRoutesPage() {
|
||||||
const { mode, backendUrl } = useDataSource()
|
return <Suspense fallback={<p role="status" className="p-6 text-sm text-muted-foreground">Загрузка маршрутов…</p>}><RecursiveRoutesRoute /></Suspense>
|
||||||
return <RecursiveRoutesContent key={JSON.stringify([mode, backendUrl])} mode={mode} backendUrl={backendUrl} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 isLive = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [servers, setServers] = useState<Array<Omit<Server, "status"> & { status: Server["status"] | null }>>([])
|
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 [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||||
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
|
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 [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||||
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
|
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
|
||||||
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
|
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
|
||||||
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
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 [opError, setOpError] = useState<string | null>(null)
|
||||||
const [liveStale, setLiveStale] = useState(true)
|
const [liveStale, setLiveStale] = useState(true)
|
||||||
const [routeOwner, setRouteOwner] = useState("")
|
const [routeOwner, setRouteOwner] = useState("")
|
||||||
@@ -418,7 +446,8 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
|||||||
if (!parsed.success) throw new Error("Некорректный список устройств в ответе API")
|
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 }))
|
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)
|
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 => {
|
.catch(error => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
@@ -430,10 +459,10 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
|||||||
if (active) setLiveServerListReady(true)
|
if (active) setLiveServerListReady(true)
|
||||||
})
|
})
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [isLive, apiFetch, inventoryTick])
|
}, [isLive, apiFetch, inventoryTick, context.serverId, context.error, patchUrl])
|
||||||
|
|
||||||
const loadRoutes = useCallback(async () => {
|
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
|
const epoch = ++readEpoch.current
|
||||||
setOpError(null); setBusy("load"); setLiveStale(true)
|
setOpError(null); setBusy("load"); setLiveStale(true)
|
||||||
try {
|
try {
|
||||||
@@ -451,10 +480,10 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
|||||||
} finally {
|
} finally {
|
||||||
if (alive.current && epoch === readEpoch.current) setBusy(null)
|
if (alive.current && epoch === readEpoch.current) setBusy(null)
|
||||||
}
|
}
|
||||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
}, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
|
||||||
|
|
||||||
const loadGateways = useCallback(async () => {
|
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
|
const epoch = ++gatewayEpoch.current
|
||||||
try {
|
try {
|
||||||
const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways?serverId=${encodeURIComponent(selectedServerId)}`))
|
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
|
if (!alive.current || epoch !== gatewayEpoch.current) return
|
||||||
setGatewayOptions([]); setGatewayError(error instanceof Error ? error.message : "Не удалось загрузить шлюзы")
|
setGatewayOptions([]); setGatewayError(error instanceof Error ? error.message : "Не удалось загрузить шлюзы")
|
||||||
}
|
}
|
||||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
}, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
void loadRoutes(); void loadGateways()
|
void loadRoutes(); void loadGateways()
|
||||||
const reads = readEpoch, gateways = gatewayEpoch
|
const reads = readEpoch, gateways = gatewayEpoch
|
||||||
return () => { reads.current++; gateways.current++ }
|
return () => { reads.current++; gateways.current++ }
|
||||||
}, [loadRoutes, loadGateways])
|
}, [loadRoutes, loadGateways, readVersion])
|
||||||
|
|
||||||
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
|
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++
|
writeLock.current = true; readEpoch.current++
|
||||||
let failed = false
|
|
||||||
setOpError(null); setSaveError(null); setBusy("apply")
|
setOpError(null); setSaveError(null); setBusy("apply")
|
||||||
try {
|
try {
|
||||||
const raw = await apiFetch<unknown>("/api/recursive-routes", { method: "PUT", body: JSON.stringify({ serverId: selectedServerId, routes: next }) })
|
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) {
|
} catch (error) {
|
||||||
if (!alive.current) return false
|
if (!alive.current) return false
|
||||||
const message = error instanceof Error ? error.message : "Не удалось применить маршруты на роутер"
|
const message = error instanceof Error ? error.message : "Не удалось применить маршруты на роутер"
|
||||||
failed = true
|
|
||||||
setOpError(message); setSaveError(message); setLiveStale(true); toast.error(message)
|
setOpError(message); setSaveError(message); setLiveStale(true); toast.error(message)
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
writeLock.current = false
|
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])
|
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
|
||||||
|
|
||||||
@@ -523,7 +552,8 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
|||||||
}, [isLive, selectedServerId, apiFetch])
|
}, [isLive, selectedServerId, apiFetch])
|
||||||
|
|
||||||
const restoreRevision = useCallback(async (id: string) => {
|
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++
|
writeLock.current = true; readEpoch.current++
|
||||||
setHistoryRestoring(true); setBusy("apply")
|
setHistoryRestoring(true); setBusy("apply")
|
||||||
try {
|
try {
|
||||||
@@ -538,9 +568,10 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
|||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
writeLock.current = false
|
writeLock.current = false
|
||||||
|
endWrite()
|
||||||
if (alive.current) { setHistoryRestoring(false); setBusy(null) }
|
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 {
|
function groupKeyOf(row: RecursiveRouteRow): string {
|
||||||
return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()])
|
return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()])
|
||||||
@@ -607,9 +638,10 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
|||||||
return saved
|
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 hasRouteSnapshot = routeOwner === selectedServerId && (!liveStale || rows.length > 0)
|
||||||
const currentServer = servers.find(s => s.id === selectedServerId)
|
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[]>(() => (
|
const rrRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
servers.map((s) => ({
|
servers.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -658,13 +690,14 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
|||||||
items={rrRailItems}
|
items={rrRailItems}
|
||||||
selectedId={selectedServerId}
|
selectedId={selectedServerId}
|
||||||
onSelect={id => {
|
onSelect={id => {
|
||||||
if (writeLock.current || id === selectedServerId) return
|
if (writeLock.current || sharedBusy || id === selectedServerId) return
|
||||||
readEpoch.current++; gatewayEpoch.current++; historyEpoch.current++
|
readEpoch.current++; gatewayEpoch.current++; historyEpoch.current++
|
||||||
setSelectedServerId(id); setRows([]); setRouteOwner(""); setLiveStale(true); setBusy(null)
|
setSelectedServerId(id); setRows([]); setRouteOwner(""); setLiveStale(true); setBusy(null)
|
||||||
setGatewayOptions([]); setGatewayError(null); setOpError(null); setSaveError(null)
|
setGatewayOptions([]); setGatewayError(null); setOpError(null); setSaveError(null)
|
||||||
setReadAt(null); setWriteAt(null); setSheetOpen(false); setHistoryOpen(false); setRevisions([]); setHistoryError(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}
|
showAll={false}
|
||||||
showCount={false}
|
showCount={false}
|
||||||
loading={isLive && !liveServerListReady}
|
loading={isLive && !liveServerListReady}
|
||||||
@@ -727,6 +760,9 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
|||||||
<p className="text-xs text-muted-foreground ml-auto">
|
<p className="text-xs text-muted-foreground ml-auto">
|
||||||
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||||
</p>
|
</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) && (
|
{(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">
|
<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}
|
{opError || saveError}
|
||||||
@@ -776,7 +812,10 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
|||||||
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
|
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
|
||||||
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
||||||
expandedKey={expandedGroupKey}
|
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}
|
mutationsDisabled={mutationsLocked}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={(g) => {
|
onDelete={(g) => {
|
||||||
@@ -813,7 +852,7 @@ function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl
|
|||||||
itemLabel="маршрутов"
|
itemLabel="маршрутов"
|
||||||
revisions={revisions}
|
revisions={revisions}
|
||||||
loading={historyLoading}
|
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}
|
restoring={historyRestoring}
|
||||||
onRestore={restoreRevision}
|
onRestore={restoreRevision}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+10
-1
@@ -26,11 +26,20 @@
|
|||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.25; операции/валидация рекурсивных маршрутов, далее URL/source |
|
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.26; URL/source маршрутов, далее observed metadata |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 10.26 — URL и source/history рекурсивных маршрутов (2026-09-30)
|
||||||
|
|
||||||
|
- server/q/route/table сохранены в URL. Default устройство записывается через replace; выбор/раскрытие используют history, поиск replace. Чужие query-поля сохранены. Missing/invalid/repeated/oversized контекст блокирует срез; route требует table. Отсутствующая группа закрывается отдельно.
|
||||||
|
- ServerRail preserveSelection исключает автоматический переход с отсутствующего устройства на первый сервер. Предварительная проверка выявила этот fallback; исправление подтверждено.
|
||||||
|
- Общий lock живёт над device context и привязан к mode/backend. URL/history не снимает pending запись; завершение перечитывает текущий контекст соответствующего источника. Late read/write старого backend/режима не меняют новый снимок и не показывают старые toast/error.
|
||||||
|
- Путь recursive-routes использует mm:network:read, как backend. Readonly/disabled устройства по-прежнему не могут сохранять или восстанавливать.
|
||||||
|
- Проверки: 38 разных основных E2E в совокупности (первый прогон 37/38, после исправления missing-device прошли все 11 links), восемь source E2E, два unit, TS/lint и production frontend 34 страницы прошли. Source mock проверки используют фактическую заглушку раздела, без требования отсутствующей mode badge.
|
||||||
|
- Далее 10.27: строгие наблюдаемые поля маршрутов/шлюзов; затем layout/keyboard/screens и профильная приёмка. Legacy mapper ещё подставляет distance/default flags, реальный RouterOS/DB/JWT не проверен. Последняя квота: 46% окна, 38% недели использовано. Локальный коммит без push.
|
||||||
|
|
||||||
## Блок 10.25 — backend-валидация рекурсивных операций (2026-09-30)
|
## Блок 10.25 — backend-валидация рекурсивных операций (2026-09-30)
|
||||||
|
|
||||||
- PUT требует явные serverId/routes и валидные параметры каждого маршрута; отсутствие routes не превращается в удаление всех managed-маршрутов. Пустой массив остаётся допустимым намеренным действием. Нули distance/scope/targetScope сохраняются.
|
- PUT требует явные serverId/routes и валидные параметры каждого маршрута; отсутствие routes не превращается в удаление всех managed-маршрутов. Пустой массив остаётся допустимым намеренным действием. Нули distance/scope/targetScope сохраняются.
|
||||||
|
|||||||
@@ -152,3 +152,7 @@ Strict receipts/serverId, общая блокировка save/delete/restore,
|
|||||||
## 10.25. Валидация PUT/restore
|
## 10.25. Валидация PUT/restore
|
||||||
|
|
||||||
Явный список и точный serverId обязательны; повреждённая версия не превращается в empty/default конфигурацию. HTTP injection проверяет отказ до сети; auth/config-sync/build/lint прошли. Intentional empty и canonical snapshot без id совместимы; алгоритм применения прежний. Далее URL/source, observed parser и layout.
|
Явный список и точный serverId обязательны; повреждённая версия не превращается в empty/default конфигурацию. HTTP injection проверяет отказ до сети; auth/config-sync/build/lint прошли. Intentional empty и canonical snapshot без id совместимы; алгоритм применения прежний. Далее URL/source, observed parser и layout.
|
||||||
|
|
||||||
|
## 10.26. URL/history/source
|
||||||
|
|
||||||
|
server/q/route/table принадлежат экрану; default replace, выбор/раскрытие history. Missing-device fallback панели запрещён. Source lock сохраняется над device context; late write освобождает lock и запускает чтение текущего среза только соответствующего источника. 38 разных основных + 8 source E2E, 2 unit, TS/lint и production frontend прошли. Далее observed metadata и layout.
|
||||||
@@ -241,6 +241,7 @@ export function permissionForPath(pathname: string): string | null {
|
|||||||
if (pathname.startsWith("/certificates")) return "mm:certificates:read"
|
if (pathname.startsWith("/certificates")) return "mm:certificates:read"
|
||||||
if (
|
if (
|
||||||
pathname.startsWith("/network") ||
|
pathname.startsWith("/network") ||
|
||||||
|
pathname.startsWith("/recursive-routes") ||
|
||||||
pathname.startsWith("/ospf") ||
|
pathname.startsWith("/ospf") ||
|
||||||
pathname.startsWith("/route-optimizer")
|
pathname.startsWith("/route-optimizer")
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import {test} from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {readRecursiveUrl,patchRecursiveUrl} from "./recursive-route-url-state"
|
||||||
|
import {permissionForPath} from "./auth"
|
||||||
|
test("recursive context roundtrip owns only its URL fields",()=>{
|
||||||
|
const params=new URLSearchParams("keep=x&server=2&q=ether&route=10.0.0.0%2F24&table=main")
|
||||||
|
assert.deepEqual(readRecursiveUrl(params),{serverId:"2",q:"ether",route:"10.0.0.0/24",table:"main",error:null})
|
||||||
|
const next=patchRecursiveUrl(params,{serverId:"1",q:null,route:null,table:null})
|
||||||
|
assert.equal(next.get("keep"),"x");assert.equal(next.get("server"),"1");assert.equal(next.has("q"),false)
|
||||||
|
assert.equal(permissionForPath("/recursive-routes"),"mm:network:read")
|
||||||
|
})
|
||||||
|
test("recursive invalid or ambiguous URL never guesses a device/group",()=>{
|
||||||
|
for(const q of ["server=0","server=srv1","server=1&server=2","route=10.0.0.0/24","table=main","q=a&q=b","q="+"x".repeat(257)]) assert.ok(readRecursiveUrl(new URLSearchParams(q)).error,q)
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type RecursiveUrlState = { serverId: string | null; q: string; route: string | null; table: string | null; error: string | null }
|
||||||
|
export type RecursiveUrlPatch = Partial<{ serverId: string | null; q: string | null; route: string | null; table: string | null }>
|
||||||
|
export function readRecursiveUrl(params: URLSearchParams): RecursiveUrlState {
|
||||||
|
const result: RecursiveUrlState = { serverId: null, q: "", route: null, table: null, error: null }
|
||||||
|
for (const [key, limit] of [["server", 64], ["q", 256], ["route", 128], ["table", 256]] as const) {
|
||||||
|
const values = params.getAll(key)
|
||||||
|
if (values.length > 1 || values.some(value => value.length > limit)) return { ...result, error: "Некорректный контекст маршрутов в ссылке" }
|
||||||
|
}
|
||||||
|
const server = params.get("server"), q = params.get("q")?.trim() ?? ""
|
||||||
|
if (server !== null && !/^[1-9]\d*$/.test(server)) return { ...result, error: "Некорректное устройство в ссылке" }
|
||||||
|
const route = params.get("route")?.trim() ?? null, table = params.get("table")?.trim() ?? null
|
||||||
|
if ((route !== null || table !== null) && (!route || !table)) return { ...result, error: "В ссылке маршрута нужны адрес и таблица" }
|
||||||
|
return { serverId: server, q, route, table, error: null }
|
||||||
|
}
|
||||||
|
export function patchRecursiveUrl(params: URLSearchParams, patch: RecursiveUrlPatch): URLSearchParams {
|
||||||
|
const next = new URLSearchParams(params)
|
||||||
|
for (const [field, value] of Object.entries(patch)) {
|
||||||
|
const key = field === "serverId" ? "server" : field
|
||||||
|
if (value === null || value.trim() === "") next.delete(key); else next.set(key, value.trim())
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
testIgnore: ["**/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: ["**/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,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "@playwright/test"
|
||||||
|
import base from "./playwright.config"
|
||||||
|
const server=base.webServer
|
||||||
|
if(!server||Array.isArray(server))throw new Error("Ожидается один локальный сервер")
|
||||||
|
export default defineConfig({
|
||||||
|
...base, use:{...base.use,baseURL:"http://127.0.0.1:3101"},testIgnore:[],testMatch:"recursive-routes-context.spec.ts",
|
||||||
|
webServer:{...server,command:"node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101",url:"http://127.0.0.1:3101",env:{...server.env,NEXT_PUBLIC_BACKEND_URL:"local"}},
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {test,expect} from "@playwright/test"
|
||||||
|
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,PUT,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
|
||||||
|
const device={id:1,name:"Роутер 1",host:"192.0.2.1",country:"RU",enabled:true,status:null}
|
||||||
|
const row=(name:string)=>({id:"one",dstAddress:"10.0.0.0/24",gateway:"192.0.2.1",distance:1,scope:30,targetScope:10,routingTable:"main",checkGateway:"ping",comment:name,disabled:false,country:""})
|
||||||
|
for(const change of ["backend","mode"])for(const operation of ["read","write"])for(const fails of [false,true])test("Маршруты: поздний "+operation+" после смены "+change+"/"+fails,async({page})=>{
|
||||||
|
let newReads=0
|
||||||
|
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}))
|
||||||
|
await page.route("**/api/**",r=>{
|
||||||
|
const u=new URL(r.request().url()),p=u.pathname;if(p==="/api/recursive-routes"&&u.port==="8101")newReads++
|
||||||
|
return r.fulfill({headers,json:p==="/api/servers"?[device]:p==="/api/auth/config"?{required:false}:p==="/api/recursive-routes"?{serverId:1,routes:[row(u.port==="8101"?"Новый снимок":"Старый снимок")],live:true,stale:false}:p==="/api/recursive-routes/gateways"?{serverId:1,gateways:[]}:{} })
|
||||||
|
})
|
||||||
|
await page.goto("/settings");await page.getByRole("link",{name:/^Рекурсивные маршруты/}).click()
|
||||||
|
await expect(page.locator("tr[data-row-id]")).toContainText("Старый снимок")
|
||||||
|
let started=false,release!:()=>void;const gate=new Promise<void>(resolve=>{release=resolve})
|
||||||
|
await page.route("**/api/recursive-routes*",async r=>{
|
||||||
|
const u=new URL(r.request().url());if(u.port!=="8000"||u.pathname!=="/api/recursive-routes"||r.request().method()!==(operation==="read"?"GET":"PUT"))return r.fallback()
|
||||||
|
started=true;await gate
|
||||||
|
return r.fulfill(fails?{headers,status:503,json:{error:"Старая ошибка маршрутов"}}:{headers,json:operation==="read"?{serverId:1,routes:[],live:true,stale:false}:{ok:true,serverId:1,routes:r.request().postDataJSON().routes,pushedRoutes:r.request().postDataJSON().routes.length}})
|
||||||
|
})
|
||||||
|
if(operation==="read")await page.getByRole("button",{name:"Обновить",exact:true}).click()
|
||||||
|
else{
|
||||||
|
await page.getByRole("button",{name:"Добавить",exact:true}).click()
|
||||||
|
const sheet=page.getByRole("dialog");await sheet.getByPlaceholder("8.8.8.8/32").fill("203.0.113.0/24");await sheet.getByPlaceholder("1.2.3.4%GW-NAME").fill("192.0.2.9")
|
||||||
|
await sheet.getByRole("button",{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:/^Рекурсивные маршруты/}).click()
|
||||||
|
if(change==="backend")await expect(page.locator("tr[data-row-id]")).toContainText("Новый снимок")
|
||||||
|
else await expect(page.getByText("Раздел работает в режиме",{exact:false})).toBeVisible()
|
||||||
|
const before=newReads,response=page.waitForResponse(r=>new URL(r.url()).port==="8000"&&new URL(r.url()).pathname==="/api/recursive-routes"&&r.request().method()===(operation==="read"?"GET":"PUT"))
|
||||||
|
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("Новый снимок")
|
||||||
|
await expect(page.getByText("Старая ошибка маршрутов",{exact:false})).toHaveCount(0)
|
||||||
|
await expect(page.locator("[data-sonner-toast]").filter({hasText:"Маршруты применены на роутер"})).toHaveCount(0)
|
||||||
|
expect(newReads).toBe(before)
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {test,expect,type Page} from "@playwright/test"
|
||||||
|
const devices=[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,country:"RU",enabled:true,status:null}))
|
||||||
|
const row=(id:number)=>({id:"r"+id,dstAddress:"10.0."+id+".0/24",gateway:"192.0.2."+id,distance:1,scope:30,targetScope:10,routingTable:"main",checkGateway:"ping",comment:"Маршрут "+id,disabled:false,country:"RU"})
|
||||||
|
async function setup(page:Page) {
|
||||||
|
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
|
||||||
|
await page.route("**/api/**",r=>{const u=new URL(r.request().url()),p=u.pathname,id=Number(u.searchParams.get("serverId"));return r.fulfill({json:p==="/api/servers"?devices:p==="/api/auth/config"?{required:false}:p==="/api/recursive-routes"?{serverId:id,routes:[row(id)],live:true,stale:false}:p==="/api/recursive-routes/gateways"?{serverId:id,gateways:[]}:{} })})
|
||||||
|
}
|
||||||
|
test("Маршруты: устройство, поиск и раскрытие переживают reload/history",async({page})=>{
|
||||||
|
await setup(page);await page.goto("/recursive-routes?server=2&q=10.0.2&route=10.0.2.0%2F24&table=main&keep=x")
|
||||||
|
await expect(page.getByRole("option").filter({hasText:"Роутер 2"})).toHaveAttribute("aria-selected","true")
|
||||||
|
await expect(page.getByRole("textbox",{name:"Поиск маршрутов"})).toHaveValue("10.0.2")
|
||||||
|
await expect(page.getByText("Endpoint 1",{exact:true})).toBeVisible()
|
||||||
|
await page.reload();await expect(page.getByText("Endpoint 1",{exact:true})).toBeVisible()
|
||||||
|
await page.getByRole("button",{name:"Сбросить поиск маршрутов"}).click()
|
||||||
|
await expect(page).not.toHaveURL(/[?&]q=/)
|
||||||
|
await page.getByRole("option").filter({hasText:"Роутер 1"}).click()
|
||||||
|
await expect(page).toHaveURL(/server=1/);await expect(page).toHaveURL(/keep=x/)
|
||||||
|
await expect(page.getByText("Endpoint 1",{exact:true})).toHaveCount(0)
|
||||||
|
await page.goBack();await expect(page.getByText("Endpoint 1",{exact:true})).toBeVisible()
|
||||||
|
await expect(page.locator("tr[data-row-id]")).toContainText("10.0.2.0/24")
|
||||||
|
})
|
||||||
|
for(const query of ["server=999","server=broken","server=1&server=2","q=a&q=b","route=10.0.1.0/24","table=main","route=a&route=b&table=main"])test("Маршруты: недоступный URL не расширяет срез "+query,async({page})=>{
|
||||||
|
await setup(page);await page.goto("/recursive-routes?"+query+"&keep=x")
|
||||||
|
const error=page.getByRole("alert").filter({hasText:"ссылк"})
|
||||||
|
await expect(error).toBeVisible();await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
|
||||||
|
await expect(page.getByRole("button",{name:"Добавить",exact:true})).toBeDisabled()
|
||||||
|
await error.getByRole("button",{name:"Сбросить контекст маршрутов"}).click()
|
||||||
|
await expect(page).toHaveURL(/keep=x/);await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||||
|
})
|
||||||
|
test("Маршруты: отсутствующая группа закрывается без сброса устройства и поиска",async({page})=>{
|
||||||
|
await setup(page);await page.goto("/recursive-routes?server=2&q=10.0&route=10.0.9.0%2F24&table=main&keep=x")
|
||||||
|
await page.getByRole("button",{name:"Закрыть маршрут",exact:true}).click()
|
||||||
|
await expect(page).toHaveURL(/server=2/);await expect(page).toHaveURL(/q=10.0/);await expect(page).not.toHaveURL(/[?&]route=/)
|
||||||
|
await expect(page.locator("tr[data-row-id]")).toContainText("10.0.2.0/24")
|
||||||
|
})
|
||||||
|
for(const fails of [false,true])test("Маршруты: history сохраняет lock до завершения записи "+fails,async({page})=>{
|
||||||
|
await setup(page);let writes=0,reads=0;let release!:()=>void;const gate=new Promise<void>(resolve=>{release=resolve})
|
||||||
|
await page.route("**/api/recursive-routes?**",r=>{reads++;const id=Number(new URL(r.request().url()).searchParams.get("serverId"));return r.fulfill({json:{serverId:id,routes:[row(id)],live:true,stale:false}})})
|
||||||
|
await page.route("**/api/recursive-routes",async r=>{writes++;await gate;const next=r.request().postDataJSON().routes;return r.fulfill(fails?{status:503,json:{error:"Старая ошибка записи"}}:{json:{ok:true,serverId:1,routes:next,pushedRoutes:next.length}})})
|
||||||
|
await page.goto("/recursive-routes?server=2")
|
||||||
|
await page.getByRole("option").filter({hasText:"Роутер 1"}).click()
|
||||||
|
await expect(page.getByRole("button",{name:"Добавить",exact:true})).toBeEnabled()
|
||||||
|
await page.getByRole("button",{name:"Добавить",exact:true}).click()
|
||||||
|
const sheet=page.getByRole("dialog")
|
||||||
|
await sheet.getByPlaceholder("8.8.8.8/32").fill("203.0.113.0/24");await sheet.getByPlaceholder("1.2.3.4%GW-NAME").fill("192.0.2.9")
|
||||||
|
await sheet.getByRole("button",{name:"Добавить",exact:true}).click();await expect.poll(()=>writes).toBe(1)
|
||||||
|
await page.goBack();await expect(page).toHaveURL(/server=2/)
|
||||||
|
await expect(page.getByRole("button",{name:"Добавить",exact:true})).toBeDisabled()
|
||||||
|
await expect(page.getByRole("status").filter({hasText:"предыдущего контекста"})).toBeVisible()
|
||||||
|
await page.goForward();await expect(page).toHaveURL(/server=1/)
|
||||||
|
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||||
|
await expect(page.getByRole("button",{name:"Добавить",exact:true})).toBeDisabled()
|
||||||
|
const before=reads,response=page.waitForResponse(r=>r.request().method()==="PUT")
|
||||||
|
release();await(await response).finished()
|
||||||
|
await expect.poll(()=>reads).toBe(before+1);await expect(page.getByRole("button",{name:"Добавить",exact:true})).toBeEnabled()
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(0);await expect(page.getByText("Старая ошибка записи",{exact:false})).toHaveCount(0)
|
||||||
|
await expect(page.locator("[data-sonner-toast]").filter({hasText:"Маршруты применены на роутер"})).toHaveCount(0)
|
||||||
|
expect(writes).toBe(1)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user