"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" 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(path: string, init?: RequestInit): Promise { return requestJson(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 (
{ 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 && ( )}
{value && ( {COUNTRY_OPTIONS.find(c => c.code === value)?.label ?? value} )}
{visible.map(c => ( ))}
) } 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(initial) const [error, setError] = useState(null) // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { setForm(initial); setError(null) }, [initial, open]) const set = (k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v })) 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() { 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 ( { if (!v) onClose() }}>
{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"} Рекурсивный статический маршрут
Основные set("dstAddress", e.target.value)} />
Конечные точки
{form.endpoints.map((ep, idx) => (
Endpoint {idx + 1}
setEp(ep.id, "country", v)} /> setEp(ep.id, "gateway", e.target.value)} />
setEp(ep.id, "distance", Number(e.target.value) || 1)} /> setEp(ep.id, "checkGateway", e.target.value)} />
setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} /> setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
{gateways.map((g) => { const value = `${g.ip}%${g.name}` const selected = ep.gateway === value const country = inferCountry(g.name) return ( ) })}
))} {gateways.length === 0 && (
Нет доступных шлюзов на выбранном роутере.
)}
Параметры set("routingTable", e.target.value)} /> set("comment", e.target.value)} />
{error &&
{error}
}
) } function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) { return ( {type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"} ) } export default function RecursiveRoutesPage() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const [servers, setServers] = useState([]) const [selectedServerId, setSelectedServerId] = useState("") const [rows, setRows] = useState([]) 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(emptyForm()) const [editingGroupKey, setEditingGroupKey] = useState(null) const [gatewayOptions, setGatewayOptions] = useState([]) const [expandedGroupKey, setExpandedGroupKey] = useState(null) const [opError, setOpError] = useState(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("/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 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() 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 (
} />
Всего маршрутов {rows.length}
{servers.map((s) => { const count = s.id === selectedServerId ? rows.length : 0 const active = selectedServerId === s.id return ( ) })}
setSearch(e.target.value)} /> {search && ( )}

{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}

{opError && (
{opError}
)}
{!isLive ? ( Раздел работает в режиме "Живые данные". Переключи источник данных в настройках. ) : ( {currentServer && (
{currentServer.name} {currentServer.host} {currentServer.asn} {currentServer.latency !== null && ( 60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", )}>{currentServer.latency}мс )}
{rows.length} маршрутов
)} ({ ...g, id: g.key }))} expandedKey={expandedGroupKey} onExpandedChange={setExpandedGroupKey} onEdit={openEdit} onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))} />
)}
setSheetOpen(false)} gateways={gatewayOptions} />
) }