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
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.
704 lines
29 KiB
TypeScript
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">
|
|
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
|
</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}
|
|
/>
|
|
</>
|
|
)
|
|
}
|