Files
MikrotikManager/app/(main)/recursive-routes/page.tsx
T
Denozordec fe32c9313a
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
feat(ui): integrate KpiStatGrid for enhanced statistics display
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
2026-09-06 17:58:05 +07:00

704 lines
29 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, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
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" | "save" | "from" | "to" | 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)
/** В 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[] }>(`/api/recursive-routes?serverId=${selectedServerId}`)
setRows(res.routes)
} catch (e) {
setRows([])
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 saveToDb = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("save")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes", {
method: "PUT",
body: JSON.stringify({ serverId: selectedServerId, routes: rows }),
})
await loadRoutes()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes])
const syncFromRouter = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("from")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
await loadRoutes()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch, loadRoutes])
const syncToRouter = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("to")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch])
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) || "",
})
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}`))
setRows(prev => [...prev, ...expanded])
} else if (editingGroupKey) {
setRows(prev => {
const kept = prev.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}`))
return [...kept, ...expanded]
})
}
setSheetOpen(false)
}
const currentServer = servers.find(s => s.id === selectedServerId)
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 />
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
{busy === "from" ? "Синхронизация..." : "Router => DB"}
</Button>
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
{busy === "to" ? "Применение..." : "DB => Router"}
</Button>
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
<SaveIcon className="size-4" />Сохранить в БД
</Button>
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
<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>
)}
</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) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
/>
<button onClick={openCreate}
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">
<PlusIcon className="size-3.5" />
Добавить маршрут
</button>
</DataPageCard>
)}
</ServerRailLayout>
<RouteSheet
open={sheetOpen}
mode={sheetMode}
initial={sheetInitial}
onSave={handleSaveSheet}
onClose={() => setSheetOpen(false)}
gateways={gatewayOptions}
/>
</>
)
}