"use client" import { useEffect, useRef, useState, useMemo } from "react" import { PageHeader } from "@/components/page-header" import { DataPageCard } from "@/components/data-page-card" import { CommunitiesDataGrid, type CommunityRow, TYPE_LABELS, ACTION_LABELS, } from "@/components/data-grids/communities-data-grid" import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { PlusIcon, SearchIcon, TagIcon, FilterIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon, } from "lucide-react" import { cn } from "@/lib/utils" import { filters } from "@/lib/data" import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status" import { useEvoBGP } from "@/lib/evobgp-context" // ─── types ──────────────────────────────────────────────────────────────────── type Community = CommunityRow type CommType = CommunityRow["type"] // ─── mock data ──────────────────────────────────────────────────────────────── const COMMUNITIES: Community[] = [ { id: "c1", value: "65001:100", name: "youtube-bypass", description: "Пометка для трафика YouTube — маршрутизация через SPB/FRA exit nodes", type: "standard", filterIds: ["f1"], serverCount: 4, prefixCount: 842, action: "local-pref", actionValue: 200, enabled: true, }, { id: "c2", value: "65001:200", name: "streaming-eu", description: "EU-стриминг (Netflix, Twitch) — выход через FRA/AMS", type: "standard", filterIds: ["f1", "f2"], serverCount: 3, prefixCount: 614, action: "local-pref", actionValue: 180, enabled: true, }, { id: "c3", value: "65001:300", name: "cdn-bypass", description: "Обход CDN-провайдеров через прямые пиринговые IX-точки", type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 4218, action: "local-pref", actionValue: 210, enabled: true, }, { id: "c4", value: "65002:100", name: "cdn-secondary", description: "Резервный CDN-путь при деградации основного", type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 1842, action: "metric", actionValue: 50, enabled: true, }, { id: "c5", value: "65001:400", name: "office-direct", description: "Office 365 / Teams — прямой выход без туннелирования", type: "standard", filterIds: ["f5"], serverCount: 6, prefixCount: 882, action: "permit", enabled: true, }, { id: "c6", value: "65001:500", name: "gaming-ll", description: "Low-latency gaming — приоритет по минимальному RTT (Discord, Steam)", type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 1212, action: "local-pref", actionValue: 250, enabled: true, }, { id: "c7", value: "65002:200", name: "gaming-ll-backup", description: "Резервный путь для gaming-low-latency", type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 412, action: "metric", actionValue: 80, enabled: true, }, { id: "c8", value: "65007:999", name: "school-block", description: "Блокировка соцсетей для школьного сегмента LAN", type: "standard", filterIds: ["f4"], serverCount: 2, prefixCount: 412, action: "deny", enabled: false, }, { id: "c9", value: "no-export", name: "no-export", description: "Не объявлять маршрут за пределы AS (RFC 1997)", type: "no-export", filterIds: [], serverCount: 1, prefixCount: 0, action: "permit", enabled: true, }, { id: "c10", value: "no-advertise", name: "no-advertise", description: "Не передавать маршрут ни одному BGP-пиру (RFC 1997)", type: "no-advertise", filterIds: [], serverCount: 1, prefixCount: 0, action: "permit", enabled: true, }, ] // ─── page ───────────────────────────────────────────────────────────────────── export default function CommunitiesPage() { const { sourceKey } = useEvoBGP() return } function CommunitiesContent() { const { mode, snapshot, loading, known } = useEvoCatalogView() const [search, setSearch] = useState("") const [typeFilter, setTypeFilter] = useState("all") const [copied, setCopied] = useState(null) const [copying, setCopying] = useState(false) const [copyError, setCopyError] = useState(null) const copyRequest = useRef({ active: true, pending: false, revision: 0, timer: null as ReturnType | null }) useEffect(() => { const state = copyRequest.current; state.active = true return () => { state.active = false; state.revision++; if (state.timer) clearTimeout(state.timer) } }, []) const [selectedId, setSelectedId] = useState(null) const listData = useMemo((): Community[] => { if (mode === "mock") return COMMUNITIES if (loading && !snapshot) return [] return snapshot?.communities ?? [] }, [mode, loading, snapshot]) const selected = known ? listData.find(row => row.id === selectedId) ?? null : null const filtered = useMemo(() => { const q = search.toLowerCase() return listData.filter(c => { const matchQ = !q || c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q) const matchT = typeFilter === "all" || c.type === typeFilter return matchQ && matchT }) }, [search, typeFilter, listData]) const handleCopy = async (value: string) => { const state = copyRequest.current if (!state.active || state.pending) return state.pending = true; const revision = ++state.revision if (state.timer) clearTimeout(state.timer) const current = () => state.active && state.revision === revision setCopying(true); setCopied(null); setCopyError(null) try { await navigator.clipboard.writeText(value) if (!current()) return setCopied(value) state.timer = setTimeout(() => { if (current()) setCopied(null) }, 1500) } catch { if (current()) setCopyError("Браузер не разрешил запись в буфер обмена. Повторите копирование или выделите значение вручную.") } finally { state.pending = false if (current()) setCopying(false) } } const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id return ( Добавить } /> {copyError && Community не скопировано{copyError}} {copying && Копирование community…} {copied && Community {copied} скопировано} , iconClassName: "text-muted-foreground", }, { id: "enabled", label: "Активных", value: known ? String(listData.filter((c) => c.enabled).length) : "—", icon: , iconClassName: "text-success", }, { id: "standard", label: "Стандартных", value: known ? String(listData.filter((c) => c.type === "standard").length) : "—", icon: , iconClassName: "text-info", }, { id: "filters", label: "Использует фильтры", value: mode === "mock" ? String(new Set(listData.flatMap((c) => c.filterIds)).size) : "—", icon: , iconClassName: "text-primary", }, ]} /> {/* ── main table ── */} {/* toolbar */} setSearch(e.target.value)} /> {(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => ( setTypeFilter(t)}> {t === "all" ? "Все" : TYPE_LABELS[t] ?? t} ))} {/* list */} {known ? setSelectedId(row.id)} onCopy={handleCopy} /> : } {/* ── detail panel ── */} {selected ? ( {selected.value}} description={selected.name} headerRight={ } contentClassName="flex flex-col gap-4 text-sm px-5 pb-5" > {selected.enabled ? "Активен" : "Выключен"} {selected.description} {[ ["Тип", TYPE_LABELS[selected.type]], ["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`], ["Маршрутов", selected.prefixCount?.toLocaleString("ru-RU") ?? "—"], ["Серверов", selected.serverCount.toLocaleString("ru-RU")], ].map(([k, v]) => ( {k} {v} ))} {selected.filterIds.length > 0 && ( Использующие фильтры {selected.filterIds.map(fid => ( {filterName(fid)} ))} )} { void handleCopy(selected.value) }}> {copied === selected.value ? : } Скопировать значение ) : ( Выберите communityдля просмотра деталей )} ) }
Копирование community…
Community {copied} скопировано
{selected.description}
Использующие фильтры
Выберите communityдля просмотра деталей