Files
DenozordecandCursor b4a3c3a925 feat(filters, recursive-routes): enhance configuration history and live data handling
- Introduced configuration history management in Filters and Recursive Routes pages, allowing users to view and restore previous configurations.
- Updated state management to handle live data loading and error states more effectively, improving user experience during data fetching.
- Added new components for displaying configuration history and integrated them into existing pages.
- Enhanced API interactions to support fetching and applying configuration revisions, ensuring data consistency across the application.
- Updated tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <[email protected]>
2026-09-11 12:15:36 +07:00

765 lines
31 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import {
RecursiveRoutesDataGrid,
type RecursiveRouteGroup,
inferCountry,
} from "@/components/data-grids/recursive-routes-data-grid"
import { FormField, SectionTitle } from "@/components/form-kit"
import { DataPageCard } from "@/components/data-page-card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source"
import { 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 { 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 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" | null
latency: number | null
}
interface GatewayOption {
id: string
name: string
ip: string
status: "up" | "down"
}
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: "Норвегия" },
]
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 {}
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): Server {
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 ?? "offline") as Server["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
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)}`,
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 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-5 gap-1.5 max-h-40 overflow-y-auto pr-0.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,
}: {
open: boolean
mode: "create" | "edit"
initial: RouteForm
onSave: (v: RouteForm) => void
onClose: () => void
gateways: GatewayOption[]
}) {
const [form, setForm] = useState<RouteForm>(initial)
const [error, setError] = useState<string | null>(null)
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setForm(initial); setError(null) }, [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) }))
function handleSave() {
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 }
setError(null)
onSave(form)
}
return (
<Sheet open={open} onOpenChange={v => { if (!v) 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">
<div>
<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>
</div>
</SheetHeader>
<div className="flex-1 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 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>
<div className="flex flex-col gap-3">
{form.endpoints.map((ep, idx) => (
<div key={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 {idx + 1}
</span>
<button
type="button"
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
className="text-muted-foreground hover:text-destructive transition-colors"
disabled={form.endpoints.length <= 1}
>
<TrashIcon 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 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 type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
</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)} />
</FormField>
</div>
<div className="grid grid-cols-2 gap-2">
<FormField label="Scope">
<Input 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 type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
</FormField>
</div>
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
{gateways.map((g) => {
const value = `${g.ip}%${g.name}`
const selected = ep.gateway === value
const country = inferCountry(g.name)
return (
<button
key={`${ep.id}-${g.id}`}
type="button"
onClick={() => setEp(ep.id, "gateway", value)}
className={cn(
"text-left rounded-lg border px-3 py-2.5 transition-all",
selected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
)}
>
<div className="flex items-center gap-2">
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "up" ? "bg-emerald-500" : "bg-red-500")} />
{country && <Flag code={country} className="shrink-0" />}
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
{selected && <CheckIcon className="size-3.5 text-primary shrink-0" />}
</div>
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
<span className="text-muted-foreground/50 break-all">{g.ip}</span>
<span className="text-muted-foreground/30">→</span>
<span className={cn("min-w-0 break-all whitespace-normal leading-tight", selected ? "text-primary font-medium" : "")}>
{value}
</span>
</div>
</button>
)
})}
</div>
</div>
))}
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
<PlusIcon 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 className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
<FormField label="Комментарий">
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</FormField>
</div>
{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>
<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>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
return (
<span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
type === "home-router" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20" :
type === "jump-host"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}>
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
</span>
)
}
export default function RecursiveRoutesPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [servers, setServers] = useState<Server[]>([])
const [selectedServerId, setSelectedServerId] = useState<string>("")
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
const [search, setSearch] = useState("")
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, setExpandedGroupKey] = useState<string | null>(null)
const [opError, setOpError] = useState<string | null>(null)
const [liveStale, setLiveStale] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [historyLoading, setHistoryLoading] = useState(false)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
const [liveServerListReady, setLiveServerListReady] = useState(false)
useEffect(() => {
if (!isLive) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLiveServerListReady(true)
setServers(mockServers)
setSelectedServerId(mockServers[0]?.id ?? "")
setRows([])
return
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setLiveServerListReady(false)
apiFetch<BackendServer[]>("/api/servers")
.then((data) => {
const mapped = data.map(mapBackendServer)
setServers(mapped)
setSelectedServerId(prev => (mapped.some(s => s.id === prev) ? prev : (mapped[0]?.id ?? "")))
})
.catch(() => {
setServers([])
setSelectedServerId("")
})
.finally(() => {
setLiveServerListReady(true)
})
}, [isLive, apiFetch])
const loadRoutes = useCallback(async () => {
if (!isLive || !liveServerListReady || !selectedServerId) return
setOpError(null)
setBusy("load")
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 : "Не удалось загрузить маршруты")
} finally {
setBusy(null)
}
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
const loadGateways = useCallback(async () => {
if (!isLive || !liveServerListReady || !selectedServerId) return
try {
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
setGatewayOptions(res.gateways)
} catch {
setGatewayOptions([])
}
}, [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])
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
if (!isLive || !selectedServerId || liveStale) return
const prev = rows
setRows(next)
setOpError(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)
toast.success("Маршруты применены на роутер")
} catch (e) {
setRows(prev)
const msg = e instanceof Error ? e.message : "Не удалось применить маршруты на роутер"
setOpError(msg)
toast.error(msg)
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, liveStale, rows, apiFetch])
const loadRevisions = useCallback(async () => {
if (!isLive || !selectedServerId) return
setHistoryLoading(true)
try {
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
)
setRevisions(res.revisions)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось загрузить историю")
setRevisions([])
} finally {
setHistoryLoading(false)
}
}, [isLive, selectedServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !selectedServerId) return
setHistoryRestoring(true)
try {
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>(
`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
)
setRows(res.routes ?? [])
setLiveStale(false)
toast.success("Версия применена на роутер")
await loadRevisions()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось откатить")
} finally {
setHistoryRestoring(false)
}
}, [isLive, selectedServerId, apiFetch, loadRevisions])
function groupKeyOf(row: RecursiveRouteRow): string {
return row.dstAddress.trim().toLowerCase()
}
function openCreate() {
setSheetMode("create")
setEditingGroupKey(null)
setSheetInitial(emptyForm())
setSheetOpen(true)
}
function openEdit(group: RouteGroup) {
setSheetMode("edit")
setEditingGroupKey(group.key)
setSheetInitial({
dstAddress: group.dstAddress,
routingTable: group.routingTable,
comment: group.comment,
endpoints: group.endpoints.map(ep => ({
id: ep.id,
gateway: ep.gateway,
distance: ep.distance,
scope: ep.scope,
targetScope: ep.targetScope,
checkGateway: ep.checkGateway,
country: ep.country || "",
})),
})
setSheetOpen(true)
}
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: false,
country: ep.country || inferCountry(ep.gateway) || "",
})
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 {
setSheetOpen(false)
return
}
setSheetOpen(false)
void applyRoutes(next)
}
const currentServer = servers.find(s => s.id === selectedServerId)
const mutationsLocked = !isLive || busy !== null || liveStale
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,
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={setSelectedServerId}
showAll={false}
showCount={false}
loading={isLive && !liveServerListReady}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
actions={
<>
<ServerRailMobileButton />
{isLive && (
<>
<Button
variant="outline"
size="sm"
onClick={() => void loadRoutes()}
disabled={busy !== null}
title="Прочитать маршруты с роутера"
>
<RefreshCwIcon className={cn("size-4", busy === "load" && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={busy !== null}
>
<HistoryIcon className="size-4" />
История
</Button>
</>
)}
<Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
<PlusIcon 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 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…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button 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>
)}
</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>
</div>
<p className="text-xs text-muted-foreground ml-auto">
{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}
</div>
)}
{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" />
Роутер недоступен — показан кэш. Изменения заблокированы.
</div>
)}
</div>
}
>
{!isLive ? (
<Frame dense className="w-full">
<FramePanel className="p-6 text-sm text-muted-foreground">
Раздел работает в режиме &quot;Живые данные&quot;. Переключи источник данных в настройках.
</FramePanel>
</Frame>
) : (
<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"} />
<Flag code={currentServer.country} size={16} />
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
<TypeChip type={currentServer.type} />
<code className="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-amber-600 dark:text-amber-400" : "text-muted-foreground",
)}>{currentServer.latency}мс</span>
)}
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{rows.length} маршрутов</span>
</div>
</div>
)}
<RecursiveRoutesDataGrid
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
expandedKey={expandedGroupKey}
onExpandedChange={setExpandedGroupKey}
onEdit={openEdit}
onDelete={(g) => {
if (mutationsLocked) return
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
}}
/>
<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 className="size-3.5" />
Добавить маршрут
</button>
</DataPageCard>
)}
</ServerRailLayout>
<RouteSheet
open={sheetOpen}
mode={sheetMode}
initial={sheetInitial}
onSave={handleSaveSheet}
onClose={() => setSheetOpen(false)}
gateways={gatewayOptions}
/>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История маршрутов"
itemLabel="маршрутов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</>
)
}