diff --git a/app/(main)/recursive-routes/page.tsx b/app/(main)/recursive-routes/page.tsx index 34d8e100..2bc64718 100644 --- a/app/(main)/recursive-routes/page.tsx +++ b/app/(main)/recursive-routes/page.tsx @@ -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(path: string, init?: RequestInit): Promise { @@ -83,7 +71,7 @@ function makeApiFetch(backendUrl: string) { } } -function mapBackendServer(s: BackendServer): Server { +function mapBackendServer(s: BackendServer): Omit & { 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 onClose: () => void gateways: GatewayOption[] + busy: boolean + saveError: string | null }) { const [form, setForm] = useState(initial) const [error, setError] = useState(null) @@ -209,16 +199,21 @@ function RouteSheet({ const setEp = (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 ( - { if (!v) onClose() }}> + { if (!v && !busy && !saveLock.current) onClose() }}>
@@ -226,10 +221,10 @@ function RouteSheet({ {mode === "create" ? "Новый маршрут" : "Редактировать маршрут"} Рекурсивный статический маршрут
- +
-
+
Основные @@ -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} > @@ -264,7 +260,7 @@ function RouteSheet({
- setEp(ep.id, "distance", Number(e.target.value) || 1)} /> + setEp(ep.id, "distance", Number(e.target.value))} /> setEp(ep.id, "checkGateway", e.target.value)} /> @@ -334,11 +330,13 @@ function RouteSheet({ set("comment", e.target.value)} />
+ {saveError &&

{saveError}

} + {busy &&

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

} {error &&
{error}
} -
+
- - + + @@ -361,10 +359,14 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) export default function RecursiveRoutesPage() { const { mode, backendUrl } = useDataSource() + return +} + +function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl: string }) { const isLive = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) - const [servers, setServers] = useState([]) + const [servers, setServers] = useState & { status: Server["status"] | null }>>([]) const [selectedServerId, setSelectedServerId] = useState("") const [rows, setRows] = useState([]) const [busy, setBusy] = useState<"load" | "apply" | null>(null) @@ -376,15 +378,27 @@ export default function RecursiveRoutesPage() { const [gatewayOptions, setGatewayOptions] = useState([]) const [expandedGroupKey, setExpandedGroupKey] = useState(null) const [opError, setOpError] = useState(null) - const [liveStale, setLiveStale] = useState(false) + 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 historyEpoch = useRef(0) 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) { @@ -395,84 +409,97 @@ export default function RecursiveRoutesPage() { setRows([]) return } - // eslint-disable-next-line react-hooks/set-state-in-effect setLiveServerListReady(false) - apiFetch("/api/servers") + let active = true + apiFetch("/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(`/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(`/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("/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(`/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(() => ( 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() { { + 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() { @@ -682,20 +722,23 @@ export default function RecursiveRoutesPage() {
Всего маршрутов - {rows.length} + {hasRouteSnapshot ? rows.length : "—"}

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

- {opError && ( -
- {opError} + {(opError || saveError) && ( +
+ {opError || saveError}
)} - {liveStale && ( + {isLive && readAt &&

Успешный ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время получения браузером, не измерения RouterOS.

} + {isLive && writeAt &&

Запись подтверждена {new Date(writeAt).toLocaleTimeString("ru-RU")}.

} + {isLive && gatewayError &&

Шлюзы не загружены: {gatewayError}. Адрес можно ввести вручную.

} + {isLive && liveStale && (
- Роутер недоступен — показан кэш. Изменения заблокированы. + {routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
)}
@@ -711,7 +754,7 @@ export default function RecursiveRoutesPage() { {currentServer && (
- + {currentServer.status ? : Состояние неизвестно} {currentServer.name} @@ -724,15 +767,17 @@ export default function RecursiveRoutesPage() { )}>{currentServer.latency}мс )}
- {rows.length} маршрутов + {hasRouteSnapshot ? rows.length + " маршрутов" : "Количество неизвестно"}
)} ({ ...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() { 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} /> diff --git a/backend/src/routes/recursive-routes.ts b/backend/src/routes/recursive-routes.ts index 517b0e17..4ed5e7cb 100644 --- a/backend/src/routes/recursive-routes.ts +++ b/backend/src/routes/recursive-routes.ts @@ -197,7 +197,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { }) } } - return reply.send({ gateways: [...map.values()] }) + return reply.send({ serverId, gateways: [...map.values()] }) } catch (err) { return reply.status(500).send({ error: String(err) }) } @@ -226,10 +226,11 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { payload: canonicalRecursiveRoutes(live), }) const stored = await mapDbRoutes(serverId) - return reply.send({ routes: stored, live: true, stale: false }) + return reply.send({ serverId, routes: stored, live: true, stale: false }) } catch (err) { app.log.warn({ serverId, err: String(err) }, "recursive live GET failed, serving cache") return reply.send({ + serverId, routes: cached, live: false, stale: true, @@ -256,7 +257,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { try { const result = await applyRecursiveToServer(server, routes, source) const stored = await mapDbRoutes(serverId) - return reply.send({ ok: true, routes: stored, pushedRoutes: result.pushed }) + return reply.send({ ok: true, serverId, routes: stored, pushedRoutes: result.pushed }) } catch (err) { return reply.status(500).send({ error: String(err) }) } @@ -308,7 +309,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { try { const result = await applyRecursiveToServer(server, routes, "rollback") const stored = await mapDbRoutes(server.id) - return reply.send({ ok: true, routes: stored, pushedRoutes: result.pushed }) + return reply.send({ ok: true, serverId: server.id, routes: stored, pushedRoutes: result.pushed }) } catch (err) { return reply.status(500).send({ error: String(err) }) } diff --git a/components/config-history-sheet.tsx b/components/config-history-sheet.tsx index 922e67f4..abd3d2ef 100644 --- a/components/config-history-sheet.tsx +++ b/components/config-history-sheet.tsx @@ -69,6 +69,7 @@ export function ConfigHistorySheet({ onRetry, restoring, onRestore, + restoreDisabled = false, }: { open: boolean onOpenChange: (open: boolean) => void @@ -79,6 +80,7 @@ export function ConfigHistorySheet({ loadError?: string | null onRetry?: () => void restoring: boolean + restoreDisabled?: boolean onRestore: (id: string) => Promise | void }) { const [pending, setPending] = useState(null) @@ -87,7 +89,7 @@ export function ConfigHistorySheet({ const [restoreError, setRestoreError] = useState(null) const busy = restoring || submitting async function confirmRestore() { - if (!pending || restoreLock.current || restoring) return + if (!pending || restoreLock.current || restoring || restoreDisabled) return restoreLock.current = true setSubmitting(true) setRestoreError(null) @@ -162,7 +164,7 @@ export function ConfigHistorySheet({ type="button" variant="outline" size="sm" - disabled={busy} + disabled={busy || restoreDisabled} onClick={() => { setRestoreError(null); setPending(rev) }} > @@ -205,7 +207,7 @@ export function ConfigHistorySheet({ setPending(null)}>Отмена void confirmRestore()} > diff --git a/components/data-grids/recursive-routes-data-grid.tsx b/components/data-grids/recursive-routes-data-grid.tsx index f79ec4c0..1b14ef8d 100644 --- a/components/data-grids/recursive-routes-data-grid.tsx +++ b/components/data-grids/recursive-routes-data-grid.tsx @@ -113,6 +113,8 @@ interface RecursiveRoutesDataGridProps { onExpandedChange?: (key: string | null) => void onEdit: (group: RecursiveRouteGroup) => void onDelete: (group: RecursiveRouteGroup) => void + mutationsDisabled?: boolean + emptyTitle?: string } function RecursiveRoutesDataGrid({ @@ -121,6 +123,8 @@ function RecursiveRoutesDataGrid({ onExpandedChange, onEdit, onDelete, + mutationsDisabled = false, + emptyTitle = "Нет маршрутов", }: RecursiveRoutesDataGridProps) { const [confirmDeleteKey, setConfirmDeleteKey] = useState(null) @@ -243,13 +247,16 @@ function RecursiveRoutesDataGrid({ const confirmDel = confirmDeleteKey === g.key return (
e.stopPropagation()} >