feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,315 @@
|
||||
"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 <CommunitiesContent key={sourceKey} />
|
||||
}
|
||||
function CommunitiesContent() {
|
||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
const [copying, setCopying] = useState(false)
|
||||
const [copyError, setCopyError] = useState<string | null>(null)
|
||||
const copyRequest = useRef({ active: true, pending: false, revision: 0, timer: null as ReturnType<typeof setTimeout> | 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<string | null>(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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
||||
actions={mode === "live" ? null : <Button size="sm">
|
||||
<PlusIcon className="size-4" />Добавить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
<EvoBgpCatalogStatus />
|
||||
{copyError && <Alert variant="warning"><AlertTitle>Community не скопировано</AlertTitle><AlertDescription>{copyError}</AlertDescription></Alert>}
|
||||
{copying && <p role="status" className="text-xs text-muted-foreground">Копирование community…</p>}
|
||||
{copied && <p role="status" className="text-xs text-muted-foreground">Community {copied} скопировано</p>}
|
||||
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка communities"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего communities",
|
||||
value: known ? String(listData.length) : "—",
|
||||
icon: <TagIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
label: "Активных",
|
||||
value: known ? String(listData.filter((c) => c.enabled).length) : "—",
|
||||
icon: <CheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "standard",
|
||||
label: "Стандартных",
|
||||
value: known ? String(listData.filter((c) => c.type === "standard").length) : "—",
|
||||
icon: <TagIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "filters",
|
||||
label: "Использует фильтры",
|
||||
value: mode === "mock" ? String(new Set(listData.flatMap((c) => c.filterIds)).size) : "—",
|
||||
icon: <FilterIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,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 flex-wrap 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 */}
|
||||
<DataPageCard>
|
||||
{known ? <CommunitiesDataGrid
|
||||
communities={filtered}
|
||||
selectedId={selected?.id}
|
||||
copiedValue={copied}
|
||||
copying={copying}
|
||||
onSelect={row => setSelectedId(row.id)}
|
||||
onCopy={handleCopy}
|
||||
/> : <EvoBgpCatalogUnavailable />}
|
||||
</DataPageCard>
|
||||
</div>
|
||||
|
||||
{/* ── detail panel ── */}
|
||||
{selected ? (
|
||||
<OpsPanel
|
||||
className="h-fit sticky top-0"
|
||||
title={<span className="font-mono">{selected.value}</span>}
|
||||
description={selected.name}
|
||||
headerRight={
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button size="sm" variant="ghost" aria-label="Изменить community" disabled className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
||||
<Button size="sm" variant="ghost" aria-label="Удалить community" disabled className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
||||
</div>
|
||||
}
|
||||
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<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?.toLocaleString("ru-RU") ?? "—"],
|
||||
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
|
||||
].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" disabled={copying} onClick={() => { void handleCopy(selected.value) }}>
|
||||
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||
Скопировать значение
|
||||
</Button>
|
||||
</div>
|
||||
</OpsPanel>
|
||||
) : (
|
||||
<Frame dense className="w-full h-fit">
|
||||
<FramePanel 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>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user