fix(ui): подтверждать операции рекурсивных маршрутов

This commit is contained in:
Denozordec committed 2026-09-30 21:08:01 +07:00
1 parent 79a8d95e7d
commit f5fc9282e2
11 files changed
+364 -138

No files matched your search

+166 -118
View File
@@ -16,9 +16,11 @@ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetT
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, PencilIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
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"
@@ -35,7 +37,7 @@ interface BackendServer {
country: string
asn: string
enabled: boolean
status: "online" | "offline" | null
status: "online" | "offline" | "degraded" | null
latency: number | null
}
@@ -61,21 +63,7 @@ const COUNTRY_OPTIONS = [
{ code: "NO", label: "Норвегия" },
]
interface RecursiveRouteRow {
id: string
dstAddress: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
routingTable: string
checkGateway: string
comment: string
disabled: boolean
country: string
}
interface RouteGroup extends RecursiveRouteGroup {}
type RouteGroup = RecursiveRouteGroup
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -83,7 +71,7 @@ function makeApiFetch(backendUrl: string) {
}
}
function mapBackendServer(s: BackendServer): Server {
function mapBackendServer(s: BackendServer): Omit<Server, "status"> & { status: Server["status"] | null } {
return {
id: String(s.id),
name: s.name || s.host,
@@ -95,7 +83,7 @@ function mapBackendServer(s: BackendServer): Server {
asn: s.asn,
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
status: s.status,
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}
@@ -191,14 +179,16 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
}
function RouteSheet({
open, mode, initial, onSave, onClose, gateways,
open, mode, initial, onSave, onClose, gateways, busy, saveError,
}: {
open: boolean
mode: "create" | "edit"
initial: RouteForm
onSave: (v: RouteForm) => void
onSave: (v: RouteForm) => Promise<boolean>
onClose: () => void
gateways: GatewayOption[]
busy: boolean
saveError: string | null
}) {
const [form, setForm] = useState<RouteForm>(initial)
const [error, setError] = useState<string | null>(null)
@@ -209,16 +199,21 @@ function RouteSheet({
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) }))
function handleSave() {
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)
onSave(form)
saveLock.current = true
try { await onSave(form) } finally { saveLock.current = false }
}
return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<Sheet open={open} onOpenChange={v => { if (!v && !busy && !saveLock.current) onClose() }}>
<SheetContent side="right" className="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">
@@ -226,10 +221,10 @@ function RouteSheet({
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button>
<Button variant="ghost" size="icon-sm" disabled={busy} aria-label="Закрыть форму маршрута" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
<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">
@@ -250,6 +245,7 @@ function RouteSheet({
type="button"
onClick={() => 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}
>
<TrashIcon className="size-3.5" />
@@ -264,7 +260,7 @@ function RouteSheet({
<div className="grid grid-cols-2 gap-2">
<FormField label="Distance (приоритет)">
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value))} />
</FormField>
<FormField label="Check Gateway">
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
@@ -334,11 +330,13 @@ function RouteSheet({
<Input 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 className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
</div>
</fieldset>
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
<Button onClick={handleSave} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
<Button type="button" variant="outline" disabled={busy} onClick={onClose} className="flex-1">Отмена</Button>
<Button type="button" disabled={busy} onClick={() => void handleSave()} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
</SheetFooter>
</SheetContent>
</Sheet>
@@ -361,10 +359,14 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
export default function RecursiveRoutesPage() {
const { mode, backendUrl } = useDataSource()
return <RecursiveRoutesContent key={JSON.stringify([mode, backendUrl])} mode={mode} backendUrl={backendUrl} />
}
function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl: string }) {
const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [servers, setServers] = useState<Server[]>([])
const [servers, setServers] = useState<Array<Omit<Server, "status"> & { status: Server["status"] | null }>>([])
const [selectedServerId, setSelectedServerId] = useState<string>("")
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
@@ -376,15 +378,27 @@ export default function RecursiveRoutesPage() {
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
const [opError, setOpError] = useState<string | null>(null)
const [liveStale, setLiveStale] = useState(false)
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 historyEpoch = useRef(0)
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) {
@@ -395,84 +409,97 @@ export default function RecursiveRoutesPage() {
setRows([])
return
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setLiveServerListReady(false)
apiFetch<BackendServer[]>("/api/servers")
let active = true
apiFetch<unknown>("/api/servers")
.then((data) => {
const mapped = data.map(mapBackendServer)
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(prev => (mapped.some(s => s.id === prev) ? prev : (mapped[0]?.id ?? "")))
})
.catch(() => {
.catch(error => {
if (!active) return
setOpError(error instanceof Error ? error.message : "Не удалось загрузить устройства")
setServers([])
setSelectedServerId("")
})
.finally(() => {
setLiveServerListReady(true)
if (active) setLiveServerListReady(true)
})
}, [isLive, apiFetch])
return () => { active = false }
}, [isLive, apiFetch, inventoryTick])
const loadRoutes = useCallback(async () => {
if (!isLive || !liveServerListReady || !selectedServerId) return
setOpError(null)
setBusy("load")
if (!isLive || !liveServerListReady || !selectedServerId || writeLock.current) return
const epoch = ++readEpoch.current
setOpError(null); setBusy("load"); setLiveStale(true)
try {
const res = await apiFetch<{ routes: RecursiveRouteRow[]; stale?: boolean }>(
`/api/recursive-routes?serverId=${selectedServerId}`,
)
setRows(res.routes)
setLiveStale(Boolean(res.stale))
} catch (e) {
setLiveStale(true)
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
const parsed = recursiveReadSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes?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 {
setBusy(null)
if (alive.current && epoch === readEpoch.current) setBusy(null)
}
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
const loadGateways = useCallback(async () => {
if (!isLive || !liveServerListReady || !selectedServerId) return
const epoch = ++gatewayEpoch.current
try {
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
setGatewayOptions(res.gateways)
} catch {
setGatewayOptions([])
const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways?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])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadRoutes()
}, [loadRoutes])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadGateways()
}, [loadGateways])
void loadRoutes(); void loadGateways()
const reads = readEpoch, gateways = gatewayEpoch
return () => { reads.current++; gateways.current++ }
}, [loadRoutes, loadGateways])
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
if (!isLive || !selectedServerId || liveStale) return
const prev = rows
setRows(next)
setOpError(null)
setBusy("apply")
if (!isLive || !selectedServerId || liveStale || routeOwner !== selectedServerId || busy !== null || writeLock.current || !can("mm:network:write")) return false
writeLock.current = true; readEpoch.current++
let failed = false
setOpError(null); setSaveError(null); setBusy("apply")
try {
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>("/api/recursive-routes", {
method: "PUT",
body: JSON.stringify({ serverId: selectedServerId, routes: next }),
})
setRows(res.routes ?? next)
setLiveStale(false)
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("Маршруты применены на роутер")
} catch (e) {
setRows(prev)
const msg = e instanceof Error ? e.message : "Не удалось применить маршруты на роутер"
setOpError(msg)
toast.error(msg)
return true
} catch (error) {
if (!alive.current) return false
const message = error instanceof Error ? error.message : "Не удалось применить маршруты на роутер"
failed = true
setOpError(message); setSaveError(message); setLiveStale(true); toast.error(message)
return false
} finally {
setBusy(null)
writeLock.current = false
if (alive.current) { setBusy(null); if (failed) void loadRoutes() }
}
}, [isLive, selectedServerId, liveStale, rows, apiFetch])
}, [isLive, selectedServerId, routeOwner, liveStale, busy, apiFetch, loadRoutes])
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
@@ -496,42 +523,47 @@ export default function RecursiveRoutesPage() {
}, [isLive, selectedServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !selectedServerId) return
setHistoryRestoring(true)
if (!isLive || !selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || writeLock.current || busy !== null || !can("mm:network:write")) throw new Error("Восстановление сейчас недоступно")
writeLock.current = true; readEpoch.current++
setHistoryRestoring(true); setBusy("apply")
try {
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>(
`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
)
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
setRows(res.routes ?? [])
setLiveStale(false)
toast.success("Версия применена на роутер")
await loadRevisions()
} catch (e) {
throw e
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 {
setHistoryRestoring(false)
writeLock.current = false
if (alive.current) { setHistoryRestoring(false); setBusy(null) }
}
}, [isLive, selectedServerId, apiFetch, loadRevisions])
}, [isLive, selectedServerId, servers, busy, apiFetch, loadRevisions])
function groupKeyOf(row: RecursiveRouteRow): string {
return row.dstAddress.trim().toLowerCase()
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: group.endpoints.map(ep => ({
endpoints: endpoints.map(ep => ({
id: ep.id,
gateway: ep.gateway,
distance: ep.distance,
@@ -543,7 +575,7 @@ export default function RecursiveRoutesPage() {
})
setSheetOpen(true)
}
function handleSaveSheet(v: RouteForm) {
async function handleSaveSheet(v: RouteForm) {
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
id,
dstAddress: v.dstAddress,
@@ -568,15 +600,16 @@ export default function RecursiveRoutesPage() {
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
next = [...kept, ...expanded]
} else {
setSheetOpen(false)
return
return false
}
setSheetOpen(false)
void applyRoutes(next)
const saved = await applyRoutes(next)
if (saved && alive.current) setSheetOpen(false)
return saved
}
const hasRouteSnapshot = routeOwner === selectedServerId && (!liveStale || rows.length > 0)
const currentServer = servers.find(s => s.id === selectedServerId)
const mutationsLocked = !isLive || busy !== null || liveStale
const mutationsLocked = !isLive || busy !== null || 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,
@@ -584,7 +617,7 @@ export default function RecursiveRoutesPage() {
host: s.host,
site: s.site,
country: s.country,
status: s.status,
status: s.status ?? undefined,
type: s.type,
enabled: s.enabled,
}))
@@ -624,7 +657,14 @@ export default function RecursiveRoutesPage() {
<ServerRailLayout
items={rrRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
onSelect={id => {
if (writeLock.current || id === selectedServerId) return
readEpoch.current++; gatewayEpoch.current++; historyEpoch.current++
setSelectedServerId(id); setRows([]); setRouteOwner(""); setLiveStale(true); setBusy(null)
setGatewayOptions([]); setGatewayError(null); setOpError(null); setSaveError(null)
setReadAt(null); setWriteAt(null); setSheetOpen(false); setHistoryOpen(false); setRevisions([]); setHistoryError(null)
setExpandedGroupKey(null)
}}
showAll={false}
showCount={false}
loading={isLive && !liveServerListReady}
@@ -639,7 +679,7 @@ export default function RecursiveRoutesPage() {
<Button
variant="outline"
size="sm"
onClick={() => void loadRoutes()}
onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
disabled={busy !== null}
title="Прочитать маршруты с роутера"
>
@@ -671,10 +711,10 @@ export default function RecursiveRoutesPage() {
<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 className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
<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 onClick={() => setSearch("")}
<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 className="size-3.5" />
</button>
@@ -682,20 +722,23 @@ export default function RecursiveRoutesPage() {
</div>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего маршрутов</span>
<span className="font-semibold tabular-nums">{rows.length}</span>
<span className="font-semibold tabular-nums">{hasRouteSnapshot ? rows.length : "—"}</span>
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p>
{opError && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
{(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>
)}
{liveStale && (
{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-amber-700 dark:text-amber-400 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2 flex items-center gap-2">
<AlertTriangleIcon className="size-3.5 shrink-0" />
Роутер недоступен — показан кэш. Изменения заблокированы.
{routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
</div>
)}
</div>
@@ -711,7 +754,7 @@ export default function RecursiveRoutesPage() {
<DataPageCard>
{currentServer && (
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
{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="font-mono text-sm font-semibold">{currentServer.name}</span>
<TypeChip type={currentServer.type} />
@@ -724,15 +767,17 @@ export default function RecursiveRoutesPage() {
)}>{currentServer.latency}мс</span>
)}
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{rows.length} маршрутов</span>
<span>{hasRouteSnapshot ? rows.length + " маршрутов" : "Количество неизвестно"}</span>
</div>
</div>
)}
<RecursiveRoutesDataGrid
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
expandedKey={expandedGroupKey}
onExpandedChange={setExpandedGroupKey}
mutationsDisabled={mutationsLocked}
onEdit={openEdit}
onDelete={(g) => {
if (mutationsLocked) return
@@ -749,11 +794,13 @@ export default function RecursiveRoutesPage() {
</ServerRailLayout>
<RouteSheet
busy={busy !== null}
saveError={saveError}
open={sheetOpen}
mode={sheetMode}
initial={sheetInitial}
onSave={handleSaveSheet}
onClose={() => setSheetOpen(false)}
onClose={() => { if (!writeLock.current) setSheetOpen(false) }}
gateways={gatewayOptions}
/>
@@ -766,6 +813,7 @@ export default function RecursiveRoutesPage() {
itemLabel="маршрутов"
revisions={revisions}
loading={historyLoading}
restoreDisabled={!can("mm:network:write") || !currentServer?.enabled || busy !== null && !historyRestoring}
restoring={historyRestoring}
onRestore={restoreRevision}
/>