"use client"
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,
type RecursiveRouteGroup,
} from "@/components/data-grids/recursive-routes-data-grid"
import { FormField, SectionTitle } from "@/components/form-kit"
import { DataPageCard } from "@/components/data-page-card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source"
import { can } from "@/lib/auth"
import { recursiveReadSchema, recursiveReceiptSchema, recursiveGatewaysSchema, recursiveServersSchema, type RecursiveRouteRow } from "@/lib/recursive-route-response"
import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, TrashIcon, SearchIcon, XIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
import { toast } from "sonner"
import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
import { type ServerTileItem } from "@/components/server-tile-rail"
interface BackendServer {
id: number
name: string
host: string
type: "jump-host" | "exit-node" | "home-router"
site: string
country: string
asn: string
enabled: boolean
status: "online" | "offline" | "degraded" | null
latency: number | null
}
interface GatewayOption {
id: string
name: string
ip: string
status: "active" | "inactive" | "unknown"
}
const COUNTRY_OPTIONS = [
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
{ code: "FI", label: "Финляндия" }, { code: "SE", label: "Швеция" },
{ code: "FR", label: "Франция" }, { code: "GB", label: "Великобритания" },
{ code: "PL", label: "Польша" }, { code: "US", label: "США" },
{ code: "UA", label: "Украина" }, { code: "TR", label: "Турция" },
{ code: "JP", label: "Япония" }, { code: "HK", label: "Гонконг" },
{ code: "KZ", label: "Казахстан" }, { code: "BY", label: "Беларусь" },
{ code: "LT", label: "Литва" }, { code: "LV", label: "Латвия" },
{ code: "EE", label: "Эстония" }, { code: "CZ", label: "Чехия" },
{ code: "AT", label: "Австрия" }, { code: "CH", label: "Швейцария" },
{ code: "NO", label: "Норвегия" },
]
type RouteGroup = RecursiveRouteGroup
function makeApiFetch(backendUrl: string) {
return async function apiFetch(path: string, init?: RequestInit): Promise {
return requestJson(backendUrl, path, init)
}
}
function mapBackendServer(s: BackendServer): Omit & { status: Server["status"] | null } {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site,
country: s.country || "UN",
asn: s.asn,
type: s.type,
enabled: s.enabled,
status: s.status,
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}
}
interface RouteForm {
dstAddress: string
routingTable: string
comment: string
endpoints: RouteEndpointForm[]
}
interface RouteEndpointForm {
id: string
disabled: boolean
gateway: string
distance: number
scope: number | null
targetScope: number | null
checkGateway: string
country: string
}
const newEndpoint = (): RouteEndpointForm => ({
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
disabled: false,
gateway: "",
distance: 1,
scope: null,
targetScope: null,
checkGateway: "ping",
country: "",
})
const emptyForm = (): RouteForm => ({
dstAddress: "",
routingTable: "main",
comment: "",
endpoints: [newEndpoint()],
})
function EndpointCountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [query, setQuery] = useState("")
const q = query.trim().toUpperCase()
const visible = q.length === 0
? COUNTRY_OPTIONS
: COUNTRY_OPTIONS.filter(c => c.code.startsWith(q) || c.label.toLowerCase().includes(query.trim().toLowerCase()))
return (
Страна
{
const v = e.target.value.toUpperCase().slice(0, 3)
setQuery(v)
if (v.length === 2) {
const match = COUNTRY_OPTIONS.find(c => c.code === v)
if (match) onChange(match.code)
else onChange(v)
}
}}
className="font-mono pr-10 h-8 text-sm" />
{value && (
)}
{value && (
{COUNTRY_OPTIONS.find(c => c.code === value)?.label ?? value}
)}
{visible.map(c => (
{ onChange(c.code); setQuery("") }}
title={`${c.code} · ${c.label}`}
className={cn(
"flex flex-col items-center gap-1 px-1 py-2 rounded-lg border text-[10px] transition-all",
value === c.code
? "border-primary bg-primary/5 ring-1 ring-primary/30 font-semibold text-primary"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40 text-muted-foreground",
)}>
{c.code}
))}
)
}
function RouteSheet({
open, mode, initial, onSave, onClose, gateways, busy, saveError,
}: {
open: boolean
mode: "create" | "edit"
initial: RouteForm
onSave: (v: RouteForm) => Promise
onClose: () => void
gateways: GatewayOption[]
busy: boolean
saveError: string | null
}) {
const [form, setForm] = useState(initial)
const [error, setError] = useState(null)
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setForm(initial); setError(null) }, [initial, open])
const set = (k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
const setEp = (id: string, k: K, v: RouteEndpointForm[K]) =>
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
const saveLock = useRef(false)
async function handleSave() {
if (busy || saveLock.current) return
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
if (form.endpoints.some(ep => !ep.gateway.trim())) { setError("У каждой конечной точки должен быть Gateway"); return }
if (!form.routingTable.trim()) { setError("Укажите таблицу маршрутизации"); return }
if (form.endpoints.some(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))) { setError("Distance, Scope и T.Scope должны быть целыми числами от 0 до 255"); return }
setError(null)
saveLock.current = true
try { await onSave(form) } finally { saveLock.current = false }
}
return (
{ if (!v && !busy && !saveLock.current) onClose() }}>
{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}
Рекурсивный статический маршрут
Основные
set("dstAddress", e.target.value)} />
Конечные точки
{form.endpoints.map((ep, idx) => (
Endpoint {idx + 1}{ep.disabled ? " · отключён" : ""}
setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
className="text-muted-foreground hover:text-destructive transition-colors"
aria-label={`Удалить конечную точку ${idx + 1}`}
disabled={form.endpoints.length <= 1}
>
setEp(ep.id, "country", v)} />
setEp(ep.id, "gateway", e.target.value)} />
setEp(ep.id, "distance", Number(e.target.value))} />
setEp(ep.id, "checkGateway", e.target.value)} />
setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
{gateways.map((g) => {
const value = `${g.ip}%${g.name}`
const selected = ep.gateway === value
return (
setEp(ep.id, "gateway", value)}
className={cn(
"text-left rounded-lg border px-3 py-2.5 transition-all",
selected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
)}
>
{g.name}
{g.status === "active" ? "Активный маршрут" : g.status === "inactive" ? "Неактивный маршрут" : "Статус неизвестен"}
{selected && }
{g.ip}
→
{value}
)
})}
))}
setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
Добавить endpoint
{gateways.length === 0 && (
Нет доступных шлюзов на выбранном роутере.
)}
Параметры
set("routingTable", e.target.value)} />
set("comment", e.target.value)} />
{saveError && {saveError}
}
{busy && Ожидание подтверждения роутера…
}
{error && }
Отмена
void handleSave()} className="flex-1"> {mode === "create" ? "Добавить" : "Сохранить"}
)
}
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
return (
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
)
}
export default function RecursiveRoutesPage() {
return Загрузка маршрутов…
}>
}
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())
const [pending, setPending] = useState([])
const [readVersions, setReadVersions] = useState>({})
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
}
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 & { status: Server["status"] | null }>>([])
const [selectedServerId, setSelectedServerId] = useState(context.serverId ?? "")
const [rows, setRows] = useState([])
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
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(emptyForm())
const [editingGroupKey, setEditingGroupKey] = useState(null)
const [gatewayOptions, setGatewayOptions] = useState([])
const expandedGroupKey = context.route && context.table ? JSON.stringify([context.route.toLowerCase(), context.table]) : null
const [opError, setOpError] = useState(null)
const [liveStale, setLiveStale] = useState(true)
const [routeOwner, setRouteOwner] = useState("")
const [readAt, setReadAt] = useState(null)
const [writeAt, setWriteAt] = useState(null)
const [saveError, setSaveError] = useState(null)
const [gatewayError, setGatewayError] = useState(null)
const historyEpoch = useRef(0)
const alive = useRef(false)
const readEpoch = useRef(0), gatewayEpoch = useRef(0), writeLock = useRef(false)
useEffect(() => {
alive.current = true
return () => { alive.current = false }
}, [])
const [historyOpen, setHistoryOpen] = useState(false)
const [historyLoading, setHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState(null)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [revisions, setRevisions] = useState([])
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
const [liveServerListReady, setLiveServerListReady] = useState(false)
const [inventoryTick, setInventoryTick] = useState(0)
useEffect(() => {
if (!isLive) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLiveServerListReady(true)
setServers(mockServers)
setSelectedServerId(mockServers[0]?.id ?? "")
setRows([])
return
}
setLiveServerListReady(false)
let active = true
apiFetch("/api/servers")
.then((data) => {
if (!active) return
const parsed = recursiveServersSchema.safeParse(data)
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(context.serverId ?? mapped[0]?.id ?? "")
if (!context.serverId && !context.error && mapped[0]) patchUrl({ serverId: mapped[0].id }, true)
})
.catch(error => {
if (!active) return
setOpError(error instanceof Error ? error.message : "Не удалось загрузить устройства")
setServers([])
setSelectedServerId("")
})
.finally(() => {
if (active) setLiveServerListReady(true)
})
return () => { active = false }
}, [isLive, apiFetch, inventoryTick, context.serverId, context.error, patchUrl])
const loadRoutes = useCallback(async () => {
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 {
const parsed = recursiveReadSchema.safeParse(await apiFetch(`/api/recursive-routes/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
if (!parsed.success) throw new Error("Некорректный снимок маршрутов в ответе API")
const res = parsed.data
if (String(res.serverId) !== selectedServerId) throw new Error("Маршруты принадлежат другому устройству")
if (!alive.current || epoch !== readEpoch.current) return
setRows(res.routes); setRouteOwner(selectedServerId); setLiveStale(res.stale)
if (res.live) setReadAt(new Date().toISOString())
else setOpError(res.error ?? "Роутер недоступен")
} catch (error) {
if (!alive.current || epoch !== readEpoch.current) return
setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось загрузить маршруты")
} finally {
if (alive.current && epoch === readEpoch.current) setBusy(null)
}
}, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
const loadGateways = useCallback(async () => {
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId)) return
const epoch = ++gatewayEpoch.current
try {
const parsed = recursiveGatewaysSchema.safeParse(await apiFetch(`/api/recursive-routes/gateways/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
if (!parsed.success) throw new Error("Некорректный список шлюзов в ответе API")
const res = parsed.data
if (String(res.serverId) !== selectedServerId) throw new Error("Шлюзы принадлежат другому устройству")
if (!alive.current || epoch !== gatewayEpoch.current) return
setGatewayOptions(res.gateways); setGatewayError(null)
} catch (error) {
if (!alive.current || epoch !== gatewayEpoch.current) return
setGatewayOptions([]); setGatewayError(error instanceof Error ? error.message : "Не удалось загрузить шлюзы")
}
}, [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, readVersion])
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
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++
setOpError(null); setSaveError(null); setBusy("apply")
try {
const raw = await apiFetch("/api/recursive-routes", { method: "PUT", body: JSON.stringify({ serverId: selectedServerId, routes: next }) })
const parsed = recursiveReceiptSchema.safeParse(raw)
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId || parsed.data.pushedRoutes !== next.length) throw new Error("Сервер не подтвердил применение маршрутов")
if (!alive.current) return false
setRows(parsed.data.routes); setLiveStale(false); setWriteAt(new Date().toISOString())
toast.success("Маршруты применены на роутер")
return true
} catch (error) {
if (!alive.current) return false
const message = error instanceof Error ? error.message : "Не удалось применить маршруты на роутер"
setOpError(message); setSaveError(message); setLiveStale(true); toast.error(message)
return false
} finally {
writeLock.current = false
endWrite()
if (alive.current) setBusy(null)
}
}, [isLive, selectedServerId, routeOwner, liveStale, busy, sharedBusy, apiFetch, beginWrite, endWrite])
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
const loadRevisions = useCallback(async () => {
if (!isLive || !selectedServerId) return
const epoch = ++historyEpoch.current
setHistoryLoading(true)
setHistoryError(null)
try {
const res = await apiFetch(
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
)
if (epoch !== historyEpoch.current) return
setRevisions(parseConfigRevisions(res, selectedServerId, "recursive-routes"))
} catch (error) {
if (epoch !== historyEpoch.current) return
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
} finally {
if (epoch === historyEpoch.current) setHistoryLoading(false)
}
}, [isLive, selectedServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
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 {
const raw = await apiFetch(`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({ serverId: selectedServerId }) })
const parsed = recursiveReceiptSchema.safeParse(raw)
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId) throw new Error("Сервер не подтвердил восстановление")
if (!alive.current) return
setRows(parsed.data.routes); setRouteOwner(selectedServerId); setLiveStale(false); setWriteAt(new Date().toISOString())
toast.success("Версия применена на роутер"); await loadRevisions()
} catch (error) {
if (alive.current) { setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось восстановить маршруты") }
throw error
} finally {
writeLock.current = false
endWrite()
if (alive.current) { setHistoryRestoring(false); setBusy(null) }
}
}, [isLive, selectedServerId, servers, busy, sharedBusy, apiFetch, loadRevisions, beginWrite, endWrite])
function groupKeyOf(row: RecursiveRouteRow): string {
return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()])
}
function openCreate() {
if (mutationsLocked) return
setSaveError(null)
setSheetMode("create")
setEditingGroupKey(null)
setSheetInitial(emptyForm())
setSheetOpen(true)
}
function openEdit(group: RouteGroup) {
if (mutationsLocked) return
setSaveError(null)
const endpoints = rows.filter(row => groupKeyOf(row) === group.key)
setSheetMode("edit")
setEditingGroupKey(group.key)
setSheetInitial({
dstAddress: group.dstAddress,
routingTable: group.routingTable,
comment: group.comment,
endpoints: endpoints.map(ep => ({
id: ep.id,
disabled: ep.disabled,
gateway: ep.gateway,
distance: ep.distance,
scope: ep.scope,
targetScope: ep.targetScope,
checkGateway: ep.checkGateway,
country: ep.country || "",
})),
})
setSheetOpen(true)
}
async function handleSaveSheet(v: RouteForm) {
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
id,
dstAddress: v.dstAddress,
gateway: ep.gateway,
distance: ep.distance,
scope: ep.scope,
targetScope: ep.targetScope,
routingTable: v.routingTable,
checkGateway: ep.checkGateway,
comment: v.comment,
disabled: ep.disabled,
country: ep.country || "",
})
let next: RecursiveRouteRow[]
if (sheetMode === "create") {
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
next = [...rows, ...expanded]
} else if (editingGroupKey) {
const kept = rows.filter(r => groupKeyOf(r) !== editingGroupKey)
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
next = [...kept, ...expanded]
} else {
return false
}
const saved = await applyRoutes(next)
if (saved && alive.current) setSheetOpen(false)
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 = Boolean(contextError) || !isLive || busy !== null || sharedBusy || liveStale || routeOwner !== selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || !can("mm:network:write")
const rrRailItems = useMemo(() => (
servers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status ?? undefined,
type: s.type,
enabled: s.enabled,
}))
), [servers])
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return rows
return rows.filter(r =>
r.dstAddress.toLowerCase().includes(q) ||
r.gateway.toLowerCase().includes(q) ||
r.routingTable.toLowerCase().includes(q) ||
r.comment.toLowerCase().includes(q),
)
}, [rows, search])
const groupedRoutes = useMemo(() => {
const map = new Map()
for (const r of filteredRows) {
const key = groupKeyOf(r)
const ex = map.get(key)
if (ex) {
ex.endpoints.push(r)
if (!ex.comment && r.comment) ex.comment = r.comment
}
else map.set(key, {
key,
dstAddress: r.dstAddress,
routingTable: r.routingTable,
comment: r.comment,
endpoints: [r],
})
}
return [...map.values()]
}, [filteredRows])
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)
patchUrl({ serverId: id, route: null, table: null })
}}
preserveSelection={true}
showAll={false}
showCount={false}
loading={isLive && !liveServerListReady}
header={
{isLive && (
<>
{ if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
disabled={busy !== null}
title="Прочитать маршруты с роутера"
>
Обновить
{
setHistoryOpen(true)
void loadRevisions()
}}
disabled={busy !== null}
>
История
>
)}
Добавить
>
}
/>
}
banner={
setSearch(e.target.value)} />
{search && (
setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
)}
Всего маршрутов
{hasRouteSnapshot ? rows.length : "—"}
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
{sharedBusy && busy !== "apply" &&
Операция предыдущего контекста ещё выполняется. Изменения заблокированы до ответа API.
}
{contextError &&
{contextError}. Выберите устройство или сбросьте ссылку. patchUrl({ serverId: null, q: null, route: null, table: null })}>Сбросить контекст маршрутов
}
{expandedGroupKey && !liveStale && !groupedRoutes.some(group => group.key === expandedGroupKey) &&
Маршрут из ссылки отсутствует в текущем срезе. patchUrl({ route: null, table: null })}>Закрыть маршрут
}
{(opError || saveError) && (
{opError || saveError}
)}
{isLive && readAt &&
Успешный ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время получения браузером, не измерения RouterOS.
}
{isLive && writeAt &&
Запись подтверждена {new Date(writeAt).toLocaleTimeString("ru-RU")}.
}
{isLive && gatewayError &&
Шлюзы не загружены: {gatewayError}. Адрес можно ввести вручную.
}
{isLive && liveStale && (
{routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
)}
}
>
{!isLive ? (
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
) : (
{currentServer && (
{currentServer.status ?
:
Состояние неизвестно }
{currentServer.name}
{currentServer.host}
{currentServer.asn}
{currentServer.latency !== null && (
60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground",
)}>{currentServer.latency}мс
)}
{hasRouteSnapshot ? rows.length + " маршрутов" : "Количество неизвестно"}
)}
({ ...g, id: g.key }))}
expandedKey={expandedGroupKey}
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) => {
if (mutationsLocked) return
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
}}
/>
Добавить маршрут
)}
{ if (!writeLock.current) setSheetOpen(false) }}
gateways={gatewayOptions}
/>
{ if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
loadError={historyError}
onRetry={() => { void loadRevisions() }}
title="История маршрутов"
itemLabel="маршрутов"
revisions={revisions}
loading={historyLoading}
restoreDisabled={!can("mm:network:write") || !currentServer?.enabled || (busy !== null || sharedBusy) && !historyRestoring}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
>
)
}