"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 => ( ))}
) } 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 ? " · отключён" : ""}
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 ( ) })}
))} {gateways.length === 0 && (
Нет доступных шлюзов на выбранном роутере.
)}
Параметры set("routingTable", e.target.value)} /> set("comment", e.target.value)} />
{saveError &&

{saveError}

} {busy &&

Ожидание подтверждения роутера…

} {error &&
{error}
}
) } 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 && ( <> )} } /> } banner={
setSearch(e.target.value)} /> {search && ( )}
Всего маршрутов {hasRouteSnapshot ? rows.length : "—"}

{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}

{sharedBusy && busy !== "apply" &&

Операция предыдущего контекста ещё выполняется. Изменения заблокированы до ответа API.

} {contextError &&

{contextError}. Выберите устройство или сбросьте ссылку.

} {expandedGroupKey && !liveStale && !groupedRoutes.some(group => group.key === expandedGroupKey) &&

Маршрут из ссылки отсутствует в текущем срезе.

} {(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} /> ) }