387 lines
18 KiB
TypeScript
387 lines
18 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useMemo, useEffect } from "react"
|
|
import { PageHeader } from "@/components/page-header"
|
|
import {
|
|
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
|
} from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
|
|
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
|
LoaderCircleIcon,
|
|
} from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
import { filters } from "@/lib/data"
|
|
import { useDataSource } from "@/lib/data-source"
|
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
|
|
|
// ─── types ────────────────────────────────────────────────────────────────────
|
|
|
|
type CommType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
|
|
|
interface Community {
|
|
id: string
|
|
value: string // e.g. "65001:100"
|
|
name: string
|
|
description: string
|
|
type: CommType
|
|
filterIds: string[] // which filters use this community
|
|
serverCount: number
|
|
prefixCount: number
|
|
action: "permit" | "deny" | "local-pref" | "metric"
|
|
actionValue?: number // e.g. local-pref value
|
|
enabled: boolean
|
|
}
|
|
|
|
// ─── 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,
|
|
},
|
|
]
|
|
|
|
const TYPE_LABELS: Record<CommType, string> = {
|
|
"standard": "Стандартный",
|
|
"no-export": "No-export",
|
|
"no-advertise":"No-advertise",
|
|
"local-as": "Local-AS",
|
|
"custom": "Кастомный",
|
|
}
|
|
|
|
const ACTION_LABELS: Record<Community["action"], string> = {
|
|
"permit": "Permit",
|
|
"deny": "Deny",
|
|
"local-pref": "Local-pref",
|
|
"metric": "MED/Metric",
|
|
}
|
|
|
|
const ACTION_COLOR: Record<Community["action"], string> = {
|
|
"permit": "text-emerald-500",
|
|
"deny": "text-red-500",
|
|
"local-pref": "text-blue-500",
|
|
"metric": "text-amber-500",
|
|
}
|
|
|
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
|
|
|
export default function CommunitiesPage() {
|
|
const { mode } = useDataSource()
|
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
|
const useEvoCatalog = mode === "live" && enabled
|
|
|
|
const [search, setSearch] = useState("")
|
|
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
|
const [copied, setCopied] = useState<string | null>(null)
|
|
const [selected, setSelected] = useState<Community | null>(null)
|
|
|
|
const listData = useMemo((): Community[] => {
|
|
if (!useEvoCatalog) return COMMUNITIES
|
|
if (loading && !snapshot) return []
|
|
return snapshot?.communities ?? []
|
|
}, [useEvoCatalog, loading, snapshot])
|
|
|
|
useEffect(() => {
|
|
setSelected(null)
|
|
}, [useEvoCatalog])
|
|
|
|
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 = (value: string) => {
|
|
navigator.clipboard.writeText(value).catch(() => {})
|
|
setCopied(value)
|
|
setTimeout(() => setCopied(null), 1500)
|
|
}
|
|
|
|
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<PageHeader
|
|
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
|
actions={
|
|
<Button size="sm">
|
|
<PlusIcon className="size-4" />Добавить
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
<div className="flex flex-col gap-5">
|
|
{useEvoCatalog && (
|
|
<p className={cn(
|
|
"text-xs flex items-center gap-1.5 -mt-1",
|
|
error ? "text-destructive" : "text-muted-foreground",
|
|
)}>
|
|
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
|
{error
|
|
? `EvoBGP: ${error}`
|
|
: snapshot?.fetchedAt
|
|
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
|
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
|
</p>
|
|
)}
|
|
|
|
{/* ── summary ── */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
{[
|
|
{ label: "Всего communities", value: String(listData.length) },
|
|
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) },
|
|
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
|
|
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
|
|
].map(({ label, value }) => (
|
|
<Card key={label}>
|
|
<CardContent className="pt-4 pb-3 px-4">
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<p className="text-2xl font-semibold tabular-nums">{value}</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[1fr_320px] gap-5">
|
|
{/* ── main table ── */}
|
|
<div className="flex flex-col gap-3">
|
|
{/* toolbar */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
<div className="relative flex-1 min-w-[180px]">
|
|
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
|
|
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
|
|
</div>
|
|
<div className="flex gap-1">
|
|
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
|
|
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
|
|
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
|
|
{t === "all" ? "Все" : TYPE_LABELS[t] ?? t}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* list */}
|
|
<Card className="overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-xs text-muted-foreground">
|
|
<th className="text-left font-medium px-4 py-2.5">Community</th>
|
|
<th className="text-left font-medium px-4 py-2.5">Имя / описание</th>
|
|
<th className="text-left font-medium px-4 py-2.5">Тип</th>
|
|
<th className="text-left font-medium px-4 py-2.5">Действие</th>
|
|
<th className="text-right font-medium px-4 py-2.5">Маршрутов</th>
|
|
<th className="text-right font-medium px-4 py-2.5">Серверов</th>
|
|
<th className="px-4 py-2.5" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map(c => (
|
|
<tr
|
|
key={c.id}
|
|
onClick={() => setSelected(c)}
|
|
className={cn(
|
|
"border-b last:border-0 cursor-pointer hover:bg-muted/40 transition-colors",
|
|
selected?.id === c.id && "bg-primary/5",
|
|
!c.enabled && "opacity-50",
|
|
)}
|
|
>
|
|
<td className="px-4 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
|
|
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
|
{c.value}
|
|
</span>
|
|
<button
|
|
onClick={e => { e.stopPropagation(); handleCopy(c.value) }}
|
|
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
|
>
|
|
{copied === c.value
|
|
? <CheckIcon className="size-3" />
|
|
: <CopyIcon className="size-3" />
|
|
}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<p className="font-medium text-xs">{c.name}</p>
|
|
<p className="text-xs text-muted-foreground line-clamp-1">{c.description}</p>
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<span className="text-xs text-muted-foreground">{TYPE_LABELS[c.type]}</span>
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
|
|
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right font-mono text-xs">
|
|
{c.prefixCount > 0 ? c.prefixCount.toLocaleString() : "—"}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<ServerIcon className="size-3 text-muted-foreground" />
|
|
<span className="font-mono text-xs">{c.serverCount}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<ChevronRightIcon className="size-4 text-muted-foreground/40" />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{filtered.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
|
<TagIcon className="size-8 opacity-30" />
|
|
<p className="text-sm">Ничего не найдено</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* ── detail panel ── */}
|
|
{selected ? (
|
|
<Card className="h-fit sticky top-0">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className={cn(
|
|
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
|
|
selected.enabled
|
|
? "bg-emerald-500/10 text-emerald-600"
|
|
: "bg-muted text-muted-foreground"
|
|
)}>
|
|
{selected.enabled ? "Активен" : "Выключен"}
|
|
</span>
|
|
</div>
|
|
<CardTitle className="text-sm font-mono">{selected.value}</CardTitle>
|
|
<CardDescription className="text-xs mt-0.5">{selected.name}</CardDescription>
|
|
</div>
|
|
<div className="flex gap-1 shrink-0">
|
|
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
|
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 text-sm">
|
|
<p className="text-xs text-muted-foreground">{selected.description}</p>
|
|
|
|
<div className="space-y-2">
|
|
{[
|
|
["Тип", TYPE_LABELS[selected.type]],
|
|
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
|
["Маршрутов", selected.prefixCount > 0 ? selected.prefixCount.toLocaleString() : "—"],
|
|
["Серверов", String(selected.serverCount)],
|
|
].map(([k, v]) => (
|
|
<div key={k} className="flex justify-between gap-2">
|
|
<span className="text-xs text-muted-foreground">{k}</span>
|
|
<span className="text-xs font-medium">{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{selected.filterIds.length > 0 && (
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1.5">
|
|
<FilterIcon className="size-3" />Использующие фильтры
|
|
</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{selected.filterIds.map(fid => (
|
|
<span key={fid} className="text-[11px] bg-muted px-2 py-0.5 rounded font-mono">
|
|
{filterName(fid)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="pt-2 border-t flex flex-col gap-2">
|
|
<Button size="sm" variant="outline" className="w-full justify-start gap-2" onClick={() => handleCopy(selected.value)}>
|
|
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
|
Скопировать значение
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card className="h-fit">
|
|
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
|
<TagIcon className="size-8 text-muted-foreground/30" />
|
|
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|