Files
Denozordec fe32c9313a
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
feat(ui): integrate KpiStatGrid for enhanced statistics display
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.
2026-09-06 17:58:05 +07:00

305 lines
13 KiB
TypeScript

"use client"
import { useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import {
CommunitiesDataGrid,
type CommunityRow,
TYPE_LABELS,
ACTION_LABELS,
ACTION_COLOR,
} 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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
PlusIcon, SearchIcon, TagIcon, FilterIcon,
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 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 { 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>
)}
<KpiStatGrid
aria-label="Сводка communities"
items={[
{
id: "all",
label: "Всего communities",
value: String(listData.length),
icon: <TagIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "enabled",
label: "Активных",
value: String(listData.filter((c) => c.enabled).length),
icon: <CheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "standard",
label: "Стандартных",
value: String(listData.filter((c) => c.type === "standard").length),
icon: <TagIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "filters",
label: "Использует фильтры",
value: String(new Set(listData.flatMap((c) => c.filterIds)).size),
icon: <FilterIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<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 */}
<DataPageCard>
<CommunitiesDataGrid
communities={filtered}
selectedId={selected?.id}
copiedValue={copied}
onSelect={setSelected}
onCopy={handleCopy}
/>
</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" 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>
}
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" onClick={() => 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>
)
}