853 lines
46 KiB
TypeScript
853 lines
46 KiB
TypeScript
"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 { RecursiveGatewayChoices, type GatewayOption } from "@/components/recursive-gateway-choices"
|
||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||
import { DataPageCard } from "@/components/data-page-card"
|
||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||
import { Badge } from "@/components/reui/badge"
|
||
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
|
||
}
|
||
|
||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||
return requestJson<T>(backendUrl, path, init)
|
||
}
|
||
}
|
||
|
||
function mapBackendServer(s: BackendServer): Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] } {
|
||
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 (
|
||
<div className="flex flex-col gap-2">
|
||
<label className="text-sm font-medium">Страна</label>
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative flex-1">
|
||
<Input aria-label="Код страны" placeholder="Поиск или код (RU, DE…)" value={query}
|
||
onChange={e => {
|
||
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 && (
|
||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||
<Flag code={value} size={20} />
|
||
</span>
|
||
)}
|
||
</div>
|
||
{value && (
|
||
<span className="text-sm font-mono text-muted-foreground shrink-0">
|
||
{COUNTRY_OPTIONS.find(c => c.code === value)?.label ?? value}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-1.5">
|
||
{visible.map(c => (
|
||
<button key={c.code} type="button"
|
||
onClick={() => { 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",
|
||
)}>
|
||
<Flag code={c.code} size={24} />
|
||
<span className="font-mono leading-none">{c.code}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RouteSheet({
|
||
open, mode, initial, onSave, onClose, gateways, busy, saveError, returnFocus,
|
||
}: {
|
||
open: boolean
|
||
mode: "create" | "edit"
|
||
initial: RouteForm
|
||
onSave: (v: RouteForm) => Promise<boolean>
|
||
onClose: () => void
|
||
gateways: GatewayOption[]
|
||
busy: boolean
|
||
saveError: string | null
|
||
returnFocus: () => HTMLElement | null
|
||
}) {
|
||
const [form, setForm] = useState<RouteForm>(initial)
|
||
const [endpointPage, setEndpointPage] = useState(0)
|
||
const lastEndpointPage = Math.max(0, Math.ceil(form.endpoints.length / 10) - 1)
|
||
if (endpointPage > lastEndpointPage) setEndpointPage(lastEndpointPage)
|
||
const currentEndpointPage = Math.min(endpointPage, lastEndpointPage)
|
||
const endpointOffset = currentEndpointPage * 10
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
useEffect(() => { setForm(initial); setError(null); setEndpointPage(0) }, [initial, open])
|
||
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
|
||
const setEp = <K extends keyof RouteEndpointForm>(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 }
|
||
const emptyGateway = form.endpoints.findIndex(ep => !ep.gateway.trim())
|
||
if (emptyGateway >= 0) { setEndpointPage(Math.floor(emptyGateway / 10)); setError(`У каждой конечной точки должен быть Gateway (Endpoint ${emptyGateway + 1})`); return }
|
||
if (!form.routingTable.trim()) { setError("Укажите таблицу маршрутизации"); return }
|
||
const invalidEndpoint = form.endpoints.findIndex(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))
|
||
if (invalidEndpoint >= 0) { setEndpointPage(Math.floor(invalidEndpoint / 10)); setError(`Distance, Scope и T.Scope должны быть целыми числами от 0 до 255 (Endpoint ${invalidEndpoint + 1})`); return }
|
||
setError(null)
|
||
saveLock.current = true
|
||
try { await onSave(form) } finally { saveLock.current = false }
|
||
}
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={v => { if (!v && !busy && !saveLock.current) onClose() }}>
|
||
<SheetContent side="right" finalFocus={returnFocus} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
|
||
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div>
|
||
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
|
||
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
|
||
</div>
|
||
<Button type="button" variant="ghost" size="icon-sm" disabled={busy} aria-label="Закрыть форму маршрута" onClick={onClose} className="shrink-0 mt-0.5"><XIcon aria-hidden="true" className="size-4" /></Button>
|
||
</div>
|
||
</SheetHeader>
|
||
<fieldset disabled={busy} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Основные</SectionTitle>
|
||
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
||
<Input aria-label="Адрес назначения" className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Конечные точки</SectionTitle>
|
||
{form.endpoints.length > 10 && <div className="flex flex-wrap items-center gap-2">
|
||
<Input key={currentEndpointPage} type="number" min={1} max={form.endpoints.length} defaultValue={endpointOffset + 1} aria-label="Перейти к endpoint" aria-describedby="recursive-endpoint-jump-hint" className="w-24 min-h-11" onKeyDown={event => {
|
||
if (event.key !== "Enter") return
|
||
event.preventDefault()
|
||
const number = Number(event.currentTarget.value)
|
||
if (Number.isInteger(number) && number >= 1 && number <= form.endpoints.length) { setEndpointPage(Math.floor((number - 1) / 10)); setError(null) }
|
||
else setError("Укажите номер endpoint от 1 до " + form.endpoints.length)
|
||
}} />
|
||
<span id="recursive-endpoint-jump-hint" className="text-xs text-muted-foreground">Номер endpoint → Enter</span>
|
||
<span role="status" className="text-xs text-muted-foreground">Endpoints {endpointOffset + 1}–{Math.min(endpointOffset + 10, form.endpoints.length)} из {form.endpoints.length}</span>
|
||
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие endpoints формы" disabled={currentEndpointPage === 0} onClick={() => setEndpointPage(currentEndpointPage - 1)}>Назад</Button>
|
||
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие endpoints формы" disabled={currentEndpointPage === lastEndpointPage} onClick={() => setEndpointPage(currentEndpointPage + 1)}>Далее</Button>
|
||
</div>}
|
||
<div className="flex flex-col gap-3">
|
||
{form.endpoints.slice(endpointOffset, endpointOffset + 10).map((ep, idx) => (
|
||
<div key={ep.id} data-recursive-endpoint-form={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||
Endpoint {endpointOffset + idx + 1}{ep.disabled ? " · отключён" : ""}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
|
||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive transition-colors"
|
||
aria-label={`Удалить конечную точку ${endpointOffset + idx + 1}`}
|
||
disabled={form.endpoints.length <= 1}
|
||
>
|
||
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
||
|
||
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
|
||
<Input aria-label="Шлюз" className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
|
||
</FormField>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<FormField label="Distance (приоритет)">
|
||
<Input aria-label="Distance (приоритет)" type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value))} />
|
||
</FormField>
|
||
<FormField label="Check Gateway">
|
||
<Input aria-label="Check Gateway" className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<FormField label="Scope">
|
||
<Input aria-label="Scope" type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
||
</FormField>
|
||
<FormField label="T.Scope">
|
||
<Input aria-label="T.Scope" type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
|
||
</FormField>
|
||
</div>
|
||
|
||
<RecursiveGatewayChoices gateways={gateways} selectedValue={ep.gateway} endpointNumber={endpointOffset + idx + 1} onChange={value => setEp(ep.id, "gateway", value)} />
|
||
</div>
|
||
))}
|
||
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => { setEndpointPage(Math.floor(form.endpoints.length / 10)); setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] })) }}>
|
||
<PlusIcon aria-hidden="true" className="size-3.5" />Добавить endpoint
|
||
</Button>
|
||
{gateways.length === 0 && (
|
||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||
Нет доступных шлюзов на выбранном роутере.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Параметры</SectionTitle>
|
||
<FormField label="Routing Table"><Input aria-label="Таблица маршрутизации" className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
|
||
<FormField label="Комментарий">
|
||
<Input aria-label="Комментарий" className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
{saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>}
|
||
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения роутера…</p>}
|
||
{error && <div role="alert" className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon aria-hidden="true" className="size-4 shrink-0" />{error}</div>}
|
||
</fieldset>
|
||
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
|
||
<Button type="button" variant="outline" disabled={busy} onClick={onClose} className="h-8 flex-none">Отмена</Button>
|
||
<Button type="button" disabled={busy} onClick={() => void handleSave()} className="h-8 flex-none"><CheckIcon aria-hidden="true" className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|
||
|
||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||
return (
|
||
<Badge size="xs" className="shrink-0 text-foreground"
|
||
variant={type === "home-router" ? "success-light" : type === "jump-host" ? "primary-light" : "info-light"}>
|
||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||
</Badge>
|
||
)
|
||
}
|
||
|
||
export default function RecursiveRoutesPage() {
|
||
return <Suspense fallback={<p role="status" className="p-6 text-sm text-muted-foreground">Загрузка маршрутов…</p>}><RecursiveRoutesRoute /></Suspense>
|
||
}
|
||
|
||
function RecursiveRoutesRoute() {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const params = useSearchParams(), context = readRecursiveUrl(new URLSearchParams(params.toString()))
|
||
const sourceKey = JSON.stringify([mode, backendUrl])
|
||
const pendingSources = useRef(new Set<string>())
|
||
const [pending, setPending] = useState<string[]>([])
|
||
const [readVersions, setReadVersions] = useState<Record<string, number>>({})
|
||
const beginWrite = useCallback(() => {
|
||
if (pendingSources.current.has(sourceKey)) return false
|
||
pendingSources.current.add(sourceKey); setPending([...pendingSources.current]); return true
|
||
}, [sourceKey])
|
||
const endWrite = useCallback(() => {
|
||
pendingSources.current.delete(sourceKey); setPending([...pendingSources.current])
|
||
setReadVersions(versions => ({ ...versions, [sourceKey]: (versions[sourceKey] ?? 0) + 1 }))
|
||
}, [sourceKey])
|
||
const patchUrl = useCallback((patch: RecursiveUrlPatch, replace = false) => {
|
||
const url = new URL(window.location.href); url.search = patchRecursiveUrl(url.searchParams, patch).toString()
|
||
const target = url.pathname + url.search + url.hash
|
||
if (replace) window.history.replaceState(null, "", target); else window.history.pushState(null, "", target)
|
||
}, [])
|
||
return <RecursiveRoutesContent key={JSON.stringify([mode, backendUrl, context.serverId, context.error])} mode={mode} backendUrl={backendUrl} context={context} patchUrl={patchUrl} beginWrite={beginWrite} endWrite={endWrite} sharedBusy={pending.includes(sourceKey)} readVersion={readVersions[sourceKey] ?? 0} />
|
||
}
|
||
|
||
function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrite, endWrite, sharedBusy, readVersion }: {
|
||
beginWrite: () => boolean; endWrite: () => void; sharedBusy: boolean; readVersion: number
|
||
mode: string; backendUrl: string; context: RecursiveUrlState; patchUrl: (patch: RecursiveUrlPatch, replace?: boolean) => void
|
||
}) {
|
||
const isLive = mode === "live"
|
||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||
|
||
const [servers, setServers] = useState<Array<Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] }>>([])
|
||
const [selectedServerId, setSelectedServerId] = useState<string>(context.serverId ?? "")
|
||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||
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<RouteForm>(emptyForm())
|
||
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
|
||
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
||
const expandedGroupKey = context.route && context.table ? JSON.stringify([context.route.toLowerCase(), context.table]) : null
|
||
const [opError, setOpError] = useState<string | null>(null)
|
||
const [liveStale, setLiveStale] = useState(true)
|
||
const [routeOwner, setRouteOwner] = useState("")
|
||
const [readAt, setReadAt] = useState<string | null>(null)
|
||
const [writeAt, setWriteAt] = useState<string | null>(null)
|
||
const [saveError, setSaveError] = useState<string | null>(null)
|
||
const [gatewayError, setGatewayError] = useState<string | null>(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<string | null>(null)
|
||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||
/** В 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<unknown>("/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, 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<unknown>(`/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<unknown>(`/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<unknown>("/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<unknown>(
|
||
`/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<unknown>(`/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()])
|
||
}
|
||
const sheetTrigger = useRef<HTMLElement | null>(null)
|
||
function returnSheetFocus() {
|
||
const target = sheetTrigger.current
|
||
if (target?.isConnected) return target
|
||
const label = target?.getAttribute("aria-label")
|
||
return label ? document.querySelector<HTMLElement>(`button[aria-label="${CSS.escape(label)}"]`) : null
|
||
}
|
||
function openCreate() {
|
||
if (mutationsLocked) return
|
||
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||
setSaveError(null)
|
||
setSheetMode("create")
|
||
setEditingGroupKey(null)
|
||
setSheetInitial(emptyForm())
|
||
setSheetOpen(true)
|
||
}
|
||
function openEdit(group: RouteGroup) {
|
||
if (mutationsLocked) return
|
||
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||
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<ServerTileItem[]>(() => (
|
||
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<string, RouteGroup>()
|
||
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 (
|
||
<>
|
||
<ServerRailLayout
|
||
items={rrRailItems}
|
||
selectedId={selectedServerId}
|
||
onSelect={id => {
|
||
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={
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
|
||
actions={
|
||
<>
|
||
<ServerRailMobileButton />
|
||
{isLive && (
|
||
<>
|
||
<Button
|
||
type="button" variant="outline"
|
||
size="sm"
|
||
onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
|
||
disabled={busy !== null}
|
||
title="Прочитать маршруты с роутера"
|
||
>
|
||
<RefreshCwIcon aria-hidden="true" className={cn("size-4", busy === "load" && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
<Button
|
||
type="button" variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setHistoryOpen(true)
|
||
void loadRevisions()
|
||
}}
|
||
disabled={busy !== null}
|
||
>
|
||
<HistoryIcon aria-hidden="true" className="size-4" />
|
||
История
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button type="button" size="sm" onClick={openCreate} disabled={mutationsLocked}>
|
||
<PlusIcon aria-hidden="true" className="size-4" />Добавить
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
banner={
|
||
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||
<SearchIcon aria-hidden="true" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||
<Input aria-label="Поиск маршрутов" className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||
{search && (
|
||
<button type="button" aria-label="Сбросить поиск маршрутов" onClick={() => setSearch("")}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||
<XIcon aria-hidden="true" className="size-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1.5 text-xs">
|
||
<span className="text-muted-foreground">Всего маршрутов</span>
|
||
<span className="font-semibold tabular-nums">{hasRouteSnapshot ? rows.length : "—"}</span>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground ml-auto">
|
||
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||
</p>
|
||
{busy === "load" && <p role="status" className="w-full text-xs text-muted-foreground">Обновление маршрутов…</p>}
|
||
{sharedBusy && busy !== "apply" && <p role="status" className="w-full text-sm text-muted-foreground">Операция предыдущего контекста ещё выполняется. Изменения заблокированы до ответа API.</p>}
|
||
{contextError && <p role="alert" className="w-full text-sm text-warning">{contextError}. Выберите устройство или сбросьте ссылку. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ serverId: null, q: null, route: null, table: null })}>Сбросить контекст маршрутов</Button></p>}
|
||
{expandedGroupKey && !liveStale && !groupedRoutes.some(group => group.key === expandedGroupKey) && <p role="status" className="w-full text-sm text-muted-foreground">Маршрут из ссылки отсутствует в текущем срезе. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ route: null, table: null })}>Закрыть маршрут</Button></p>}
|
||
{(opError || saveError) && (
|
||
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2" role="alert">
|
||
{opError || saveError}
|
||
</div>
|
||
)}
|
||
{isLive && readAt && <p role="status" className="w-full text-xs text-muted-foreground">Успешный ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время получения браузером, не измерения RouterOS.</p>}
|
||
{isLive && writeAt && <p role="status" className="w-full text-xs text-muted-foreground">Запись подтверждена {new Date(writeAt).toLocaleTimeString("ru-RU")}.</p>}
|
||
{isLive && gatewayError && <p role="status" className="w-full text-xs text-warning">Шлюзы не загружены: {gatewayError}. Адрес можно ввести вручную.</p>}
|
||
{isLive && liveStale && (
|
||
<div className="w-full text-xs text-warning bg-warning/10 border border-warning/20 rounded-md px-3 py-2 flex items-center gap-2">
|
||
<AlertTriangleIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||
{routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
|
||
</div>
|
||
)}
|
||
</div>
|
||
}
|
||
>
|
||
{!isLive ? (
|
||
<Frame dense className="w-full">
|
||
<FramePanel className="p-6 text-sm text-muted-foreground">
|
||
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
||
</FramePanel>
|
||
</Frame>
|
||
) : (
|
||
<DataPageCard>
|
||
{currentServer && (
|
||
<div className="flex flex-wrap items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
||
{currentServer.status ? <StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> : <span className="text-xs text-muted-foreground">Состояние неизвестно</span>}
|
||
<Flag code={currentServer.country} size={16} />
|
||
<span className="min-w-0 break-all font-mono text-sm font-semibold">{currentServer.name}</span>
|
||
{currentServer.type && <TypeChip type={currentServer.type} />}
|
||
<code className="break-all text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
|
||
<span className="text-xs text-muted-foreground">{currentServer.asn}</span>
|
||
{currentServer.latency !== null && (
|
||
<span className={cn(
|
||
"text-xs font-mono",
|
||
currentServer.latency > 60 ? "text-warning" : "text-muted-foreground",
|
||
)}>{currentServer.latency}мс</span>
|
||
)}
|
||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
<span>{hasRouteSnapshot ? rows.length + " маршрутов" : "Количество неизвестно"}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<RecursiveRoutesDataGrid
|
||
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
|
||
groups={groupedRoutes.map((g) => ({ ...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))
|
||
}}
|
||
/>
|
||
<button type="button" onClick={openCreate} disabled={mutationsLocked}
|
||
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
|
||
<PlusIcon aria-hidden="true" className="size-3.5" />
|
||
Добавить маршрут
|
||
</button>
|
||
</DataPageCard>
|
||
)}
|
||
</ServerRailLayout>
|
||
|
||
<RouteSheet
|
||
returnFocus={returnSheetFocus}
|
||
busy={busy !== null}
|
||
saveError={saveError}
|
||
open={sheetOpen}
|
||
mode={sheetMode}
|
||
initial={sheetInitial}
|
||
onSave={handleSaveSheet}
|
||
onClose={() => { if (!writeLock.current) setSheetOpen(false) }}
|
||
gateways={gatewayOptions}
|
||
/>
|
||
|
||
<ConfigHistorySheet
|
||
open={historyOpen}
|
||
onOpenChange={v => { 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}
|
||
/>
|
||
</>
|
||
)
|
||
}
|