fix(evolution): исключить моковые данные из живого каталога

This commit is contained in:
Denozordec committed 2026-10-03 14:08:44 +07:00
1 parent 118cd31d65
commit d272ef57f0
17 files changed
+155 -136

No files matched your search

+12 -27
View File
@@ -8,25 +8,22 @@ import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { asns as mockAsns } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { toast } from "sonner"
export default function AsnsPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const { mode, snapshot, loading, known } = useEvoCatalogView()
const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled
const useEvoCatalog = mode === "live"
const rows = useMemo(() => {
if (!useEvoCatalog) return mockAsns
if (mode === "mock") return mockAsns
if (loading && !snapshot) return []
return snapshot?.asns ?? []
}, [useEvoCatalog, loading, snapshot])
}, [mode, loading, snapshot])
const filtered = useMemo(() => {
if (!search) return rows
@@ -43,8 +40,7 @@ export default function AsnsPage() {
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
actions={
<>
actions={mode === "live" ? null : <>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
@@ -60,32 +56,21 @@ export default function AsnsPage() {
<p className="text-sm text-muted-foreground mt-1">
Автономные системы — источники маршрутов для BGP-фильтров
</p>
{useEvoCatalog && (
<p className={cn(
"text-xs mt-2 flex items-center gap-1.5",
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>
)}
<div className="mt-3"><EvoBgpCatalogStatus /></div>
</div>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по ASN, имени, префиксам…"
countLabel={`${filtered.length} ASN`}
countLabel={known ? `${filtered.length} ASN` : "Количество: —"}
/>
<AsnsDataGrid
{known || loading ? <AsnsDataGrid
asns={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
/> : <EvoBgpCatalogUnavailable />}
</DataPageCard>
</div>
</div>
+24 -39
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useMemo, useEffect } from "react"
import { useState, useMemo } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import {
@@ -8,7 +8,6 @@ import {
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"
@@ -18,11 +17,10 @@ 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 { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { useEvoBGP } from "@/lib/evobgp-context"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -98,24 +96,24 @@ const COMMUNITIES: Community[] = [
// ─── page ─────────────────────────────────────────────────────────────────────
export default function CommunitiesPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const useEvoCatalog = mode === "live" && enabled
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 [selected, setSelected] = useState<Community | null>(null)
const [selectedId, setSelectedId] = useState<string | null>(null)
const listData = useMemo((): Community[] => {
if (!useEvoCatalog) return COMMUNITIES
if (mode === "mock") return COMMUNITIES
if (loading && !snapshot) return []
return snapshot?.communities ?? []
}, [useEvoCatalog, loading, snapshot])
}, [mode, loading, snapshot])
useEffect(() => {
setSelected(null)
}, [useEvoCatalog])
const selected = known ? listData.find(row => row.id === selectedId) ?? null : null
const filtered = useMemo(() => {
const q = search.toLowerCase()
@@ -138,8 +136,7 @@ export default function CommunitiesPage() {
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
actions={
<Button size="sm">
actions={mode === "live" ? null : <Button size="sm">
<PlusIcon className="size-4" />Добавить
</Button>
}
@@ -147,19 +144,7 @@ export default function CommunitiesPage() {
<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>
)}
<EvoBgpCatalogStatus />
<KpiStatGrid
aria-label="Сводка communities"
@@ -167,35 +152,35 @@ export default function CommunitiesPage() {
{
id: "all",
label: "Всего communities",
value: String(listData.length),
value: known ? String(listData.length) : "—",
icon: <TagIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "enabled",
label: "Активных",
value: String(listData.filter((c) => c.enabled).length),
value: known ? 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),
value: known ? 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),
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-[1fr_320px] gap-5">
<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 */}
@@ -204,7 +189,7 @@ export default function CommunitiesPage() {
<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">
<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)}>
@@ -216,13 +201,13 @@ export default function CommunitiesPage() {
{/* list */}
<DataPageCard>
<CommunitiesDataGrid
{known ? <CommunitiesDataGrid
communities={filtered}
selectedId={selected?.id}
copiedValue={copied}
onSelect={setSelected}
onSelect={row => setSelectedId(row.id)}
onCopy={handleCopy}
/>
/> : <EvoBgpCatalogUnavailable />}
</DataPageCard>
</div>
@@ -234,8 +219,8 @@ export default function CommunitiesPage() {
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>
<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"
+13 -28
View File
@@ -8,25 +8,22 @@ import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { domains as mockDomains } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { toast } from "sonner"
export default function DomainsPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const { mode, snapshot, loading, known } = useEvoCatalogView()
const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled
const useEvoCatalog = mode === "live"
const rows = useMemo(() => {
if (!useEvoCatalog) return mockDomains
if (mode === "mock") return mockDomains
if (loading && !snapshot) return []
return snapshot?.domains ?? []
}, [useEvoCatalog, loading, snapshot])
}, [mode, loading, snapshot])
const filtered = useMemo(() => {
if (!search) return rows
@@ -43,8 +40,7 @@ export default function DomainsPage() {
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
actions={
<>
actions={mode === "live" ? null : <>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
@@ -58,34 +54,23 @@ export default function DomainsPage() {
<div>
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
<p className="text-sm text-muted-foreground mt-1">
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik
Домены из каталога для BGP-фильтрации
</p>
{useEvoCatalog && (
<p className={cn(
"text-xs mt-2 flex items-center gap-1.5",
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>
)}
<div className="mt-3"><EvoBgpCatalogStatus /></div>
</div>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по домену…"
countLabel={`${filtered.length} доменов`}
countLabel={known ? `${filtered.length} доменов` : "Количество: —"}
/>
<DomainsDataGrid
{known || loading ? <DomainsDataGrid
domains={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
/> : <EvoBgpCatalogUnavailable />}
</DataPageCard>
</div>
</div>
+10 -5
View File
@@ -23,6 +23,7 @@ import {
type FilterRule, type ServerFilterRuleset, type Server, type GreTunnel,
} from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { can } from "@/lib/auth"
import { useEvoBGP } from "@/lib/evobgp-context"
import {
PlusIcon, SearchIcon, XIcon, CheckIcon, AlertCircleIcon,
@@ -1280,9 +1281,9 @@ function FiltersPage() {
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const communityNameMap = useMemo(() => {
if (mode === "live" && evo.enabled) {
if (mode === "live") {
const m: Record<string, string> = {}
for (const c of evo.snapshot?.communities ?? []) {
for (const c of evo.enabled && can("mm:settings:admin") ? evo.snapshot?.communities ?? [] : []) {
const v = (c.value ?? "").trim()
if (!v) continue
m[v] = c.name?.trim() || v
@@ -1290,7 +1291,7 @@ function FiltersPage() {
return m
}
return { ...COMMUNITY_NAMES }
}, [mode, evo.enabled, evo.snapshot?.communities, evo.snapshot?.fetchedAt])
}, [mode, evo.enabled, evo.snapshot?.communities])
const communitySuggestions = useMemo(
() => Object.keys(communityNameMap).sort((a, b) => a.localeCompare(b)),
@@ -1298,10 +1299,14 @@ function FiltersPage() {
)
const evoCommunityHint = useMemo(() => {
if (mode !== "live" || !evo.enabled) return null
if (mode !== "live") return null
if (!can("mm:settings:admin")) return "Справочник EvoBGP недоступен по правам; значение community можно ввести вручную"
if (!evo.settingsLoaded) return "Настройки справочника EvoBGP неизвестны; значение community можно ввести вручную"
if (!evo.enabled) return "Интеграция EvoBGP выключена; значение community можно ввести вручную"
if (evo.error) return "Справочник EvoBGP не обновлён: " + evo.error
if (evo.loading) return "Загрузка справочника communities из EvoBGP…"
return null
}, [mode, evo.enabled, evo.loading])
}, [mode, evo.settingsLoaded, evo.enabled, evo.error, evo.loading])
const [liveServers, setLiveServers] = useState<Server[]>([])
/** GRE с роутера только по запрошенным serverId (текущий + цели копирования) */
+13 -28
View File
@@ -8,25 +8,22 @@ import { IpRangesDataGrid } from "@/components/data-grids/ip-ranges-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { ipRanges as mockIpRanges } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
import { toast } from "sonner"
export default function IpRangesPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const { mode, snapshot, loading, known } = useEvoCatalogView()
const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled
const useEvoCatalog = mode === "live"
const rows = useMemo(() => {
if (!useEvoCatalog) return mockIpRanges
if (mode === "mock") return mockIpRanges
if (loading && !snapshot) return []
return snapshot?.ipRanges ?? []
}, [useEvoCatalog, loading, snapshot])
}, [mode, loading, snapshot])
const filtered = useMemo(() => {
if (!search) return rows
@@ -44,8 +41,7 @@ export default function IpRangesPage() {
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
actions={
<>
actions={mode === "live" ? null : <>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
@@ -59,34 +55,23 @@ export default function IpRangesPage() {
<div>
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
<p className="text-sm text-muted-foreground mt-1">
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам
CIDR-блоки из каталога для сетевых фильтров
</p>
{useEvoCatalog && (
<p className={cn(
"text-xs mt-2 flex items-center gap-1.5",
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>
)}
<div className="mt-3"><EvoBgpCatalogStatus /></div>
</div>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по CIDR, ASN…"
countLabel={`${filtered.length} диапазонов`}
countLabel={known ? `${filtered.length} диапазонов` : "Количество: —"}
/>
<IpRangesDataGrid
{known || loading ? <IpRangesDataGrid
ipRanges={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
/> : <EvoBgpCatalogUnavailable />}
</DataPageCard>
</div>
</div>
+3 -2
View File
@@ -12,6 +12,7 @@ import {
} from "@/components/ui/sidebar"
import { navigationGroups } from "@/lib/navigation"
import { useDataSource } from "@/lib/data-source"
import { can } from "@/lib/auth"
import { useEvoBGP } from "@/lib/evobgp-context"
import { requestJson } from "@/shared/api/http-client"
import {
@@ -78,7 +79,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (mode !== "live") return mockBadges[url]
if (url === "/domains" || url === "/ip-ranges" || url === "/asns") {
if (!evo.enabled) return undefined
if (!evo.settingsLoaded || !evo.enabled || !can("mm:settings:admin")) return undefined
if (!evo.snapshot) return undefined
if (url === "/domains") return formatSidebarBadgeCount(evo.snapshot.domains.length)
if (url === "/ip-ranges") return formatSidebarBadgeCount(evo.snapshot.ipRanges.length)
@@ -111,7 +112,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
badge: mounted ? badgeFor(it.url) : undefined,
})),
}))
}, [mounted, mode, liveCounts, mockBadges, evo.enabled, evo.snapshot])
}, [mounted, mode, liveCounts, mockBadges, evo.settingsLoaded, evo.enabled, evo.snapshot])
return (
<Sidebar collapsible="icon" {...props}>
+3 -2
View File
@@ -21,10 +21,11 @@ import { FilterIcon, NetworkIcon } from "lucide-react"
interface AsnsDataGridProps {
asns: Asn[]
isLoading?: boolean
emptyDescription?: string
pagination?: boolean
}
function AsnsDataGrid({ asns, isLoading, pagination = false }: AsnsDataGridProps) {
function AsnsDataGrid({ asns, isLoading, pagination = false, emptyDescription }: AsnsDataGridProps) {
const columns = useMemo<ColumnDef<DataGridFeatures, Asn>[]>(
() => [
{
@@ -133,7 +134,7 @@ function AsnsDataGrid({ asns, isLoading, pagination = false }: AsnsDataGridProps
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет ASN"
description="Добавьте автономные системы или импортируйте каталог"
description={emptyDescription ?? "Добавьте автономные системы или импортируйте каталог"}
className="border-0 py-12"
/>
}
+3 -2
View File
@@ -21,10 +21,11 @@ import { FilterIcon, GlobeIcon } from "lucide-react"
interface DomainsDataGridProps {
domains: Domain[]
isLoading?: boolean
emptyDescription?: string
pagination?: boolean
}
function DomainsDataGrid({ domains, isLoading, pagination = false }: DomainsDataGridProps) {
function DomainsDataGrid({ domains, isLoading, pagination = false, emptyDescription }: DomainsDataGridProps) {
const columns = useMemo<ColumnDef<DataGridFeatures, Domain>[]>(
() => [
{
@@ -142,7 +143,7 @@ function DomainsDataGrid({ domains, isLoading, pagination = false }: DomainsData
<EmptyState
icon={<GlobeIcon className="size-4" />}
title="Нет доменов"
description="Добавьте домены или импортируйте каталог"
description={emptyDescription ?? "Добавьте домены или импортируйте каталог"}
className="border-0 py-12"
/>
}
@@ -21,10 +21,11 @@ import { FilterIcon, NetworkIcon } from "lucide-react"
interface IpRangesDataGridProps {
ipRanges: IpRange[]
isLoading?: boolean
emptyDescription?: string
pagination?: boolean
}
function IpRangesDataGrid({ ipRanges, isLoading, pagination = false }: IpRangesDataGridProps) {
function IpRangesDataGrid({ ipRanges, isLoading, pagination = false, emptyDescription }: IpRangesDataGridProps) {
const columns = useMemo<ColumnDef<DataGridFeatures, IpRange>[]>(
() => [
{
@@ -144,7 +145,7 @@ function IpRangesDataGrid({ ipRanges, isLoading, pagination = false }: IpRangesD
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет IP-диапазонов"
description="Добавьте CIDR-блоки или импортируйте каталог"
description={emptyDescription ?? "Добавьте CIDR-блоки или импортируйте каталог"}
className="border-0 py-12"
/>
}
@@ -0,0 +1,29 @@
"use client"
import Link from "next/link"
import {useDataSource} from "@/lib/data-source"
import {useEvoBGP} from "@/lib/evobgp-context"
import {can} from "@/lib/auth"
import {Alert,AlertTitle,AlertDescription} from "@/components/reui/alert"
import {Button} from "@/components/ui/button"
import {EmptyState} from "@/components/empty-state"
import {AlertCircleIcon} from "lucide-react"
export function useEvoCatalogView(){
const {mode,backendStatus}=useDataSource(),evo=useEvoBGP()
const allowed=can("mm:settings:admin")
const snapshot=mode==="live"&&allowed&&evo.enabled?evo.snapshot:null
return {evo,mode,backendStatus,allowed,snapshot,known:mode==="mock"||!!snapshot,loading:mode==="live"&&allowed&&!snapshot&&(evo.settingsLoading||evo.loading)}
}
export function EvoBgpCatalogStatus(){
const {evo,mode,backendStatus,allowed,snapshot}=useEvoCatalogView()
if(mode==="mock")return <p role="status" className="text-xs text-muted-foreground">Демонстрационный каталог</p>
const reason=!allowed?"Нет доступа к каталогу EvoBGP":!evo.settingsLoaded?evo.settingsLoading?"Загрузка настроек EvoBGP…":"Настройки EvoBGP неизвестны":!evo.enabled?"Интеграция EvoBGP выключена":!snapshot?evo.loading?"Загрузка каталога EvoBGP…":"Каталог EvoBGP не получен":null
const error=evo.settingsError||evo.catalogError
return <div className="flex min-w-0 flex-col gap-3">
{reason?<p role="status" className="text-sm">{reason}</p>:null}
{snapshot?<p role="status" className="text-xs text-muted-foreground">Каталог EvoBGP получен: {evo.catalogReceivedAt?new Date(evo.catalogReceivedAt).toLocaleString("ru-RU"):"—"}{evo.loading?" · обновление…":""}</p>:null}
{error?<Alert variant="warning"><AlertCircleIcon aria-hidden="true"/><AlertTitle>{snapshot?"Каталог не обновлён; показаны последние данные":"Каталог EvoBGP недоступен"}</AlertTitle><AlertDescription>{[evo.settingsError,evo.catalogError].filter(Boolean).join("; ")}</AlertDescription></Alert>:null}
{allowed?<div className="flex flex-wrap gap-2"><Button size="sm" variant="outline" disabled={backendStatus!==true||evo.settingsLoading||evo.loading||evo.operationBusy} onClick={()=>{if(!evo.settingsLoaded||!evo.enabled||evo.settingsError)void evo.loadSettings();else void evo.refresh()}}>Обновить каталог EvoBGP</Button><Link className="text-sm underline underline-offset-4" href="/settings">Настройки → EvoBGP</Link></div>:null}
</div>
}
export function EvoBgpCatalogUnavailable(){return <EmptyState title="Данные каталога не получены" description="Состояние загрузки указано выше. Число записей пока неизвестно." className="py-10"/>}
+2
View File
@@ -280,3 +280,5 @@ REST запрос → ожидание → подтверждённый output /
Завершён 11Б.3.5.3.2: strict upstream catalog, nullable ASN/community counts, unknown «—» вместо 0. Parser/aggregation/HTTP/JWT/build/typecheck/lint и provider/counts E2E прошли; locator inline details исправлен. Реальный Evolution/PG не принят. Далее 11Б.3.5.4.1 — форма Evolution dirty/rights/lock/key clear/source.
Завершён 11Б.3.5.4.1: отдельная форма Evolution dirty/rights/lock/clear confirmation/source; 17 основных, 8 source и 3 responsive E2E, typecheck/lint/build прошли. 6 снимков, 320/ПК/clear просмотрены. Общие Settings и live consumer mocks ещё не приняты. Далее 11Б.3.5.4.2 — consumers/sidebar unknown/disabled/no fallback.
Завершён 11Б.3.5.4.2: live catalog consumers/sidebar/Filters без demo fallback, unknown/disabled/last snapshot и current details; 25 E2E, typecheck/профильный lint/build прошли. 3 community screenshots, 320/ПК просмотрены. Sidebar sync-effect baseline error и Filters unused warnings описаны в журнале. Далее 11Б.3.5.5 — HTTP/JWT catalog и приёмка Evolution.
+10 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.4.1; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.4.2; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,15 @@
## Блок 11Б.3.5.4.2 — live потребители каталога (2026-10-03)
- Domains/IP ranges/ASN/communities используют mock rows только в mock mode. В live unknown/disabled/no access не становится демонстрационными строками или подтверждённым нулём. Общий status разделяет unread/disabled/error/last snapshot/freshness; retry может перечитать настройки отключённой интеграции. Подтверждённый empty catalogue и search no-results имеют отдельные подсказки.
- Не реализованные import/export/add controls скрыты в live; исправлены пустые действия community details. Sidebar badges требуют known enabled/authorized snapshot. Live community suggestions Filters не используют demo names при unavailable integration, ручной ввод сохранён; сетевые правила не менялись.
- Community details выбираются по ID из текущего snapshot, а не сохраняют устаревший объект; source перемонтирует локальный контекст. Unknown KPI «—»; filter association KPI live «—», поскольку association IDs upstream не подтверждены. Таблица/детали и filters toolbar адаптированы для телефона.
- 14 первоначальных consumers/counts E2E и итоговые 25 consumers/Filters data/save (18 consumers, 7 Filters) прошли. Общий typecheck, профильный ESLint и production build прошли. Filters имеет 12 прежних unused warnings (лишняя hook dependency убрана); Sidebar сохраняет исходную sync-effect lint error, она не относится к новым catalog badges.
- 3 снимка communities 1440/390/320, 320/desktop просмотрены. Реальный Evolution/PG/Portal не принят. Действия mock-импорта остаются демонстрационными.
- Далее 11Б.3.5.5 — DI HTTP/JWT catalog mapping/error/unknown receipt и локальная приёмка Evolution. Затем общие Settings и оставшаяся группа 11Б.
## Блок 11Б.3.5.4.1 — форма Evolution (2026-10-03)
- Evolution вынесен из Settings в отдельную форму на существующих Frame/OpsPanel/FormField/FormToggle/AlertDialog. GET обновляет только нетронутые поля; ключ и другие dirty поля сохраняются при обновлении и ошибке. PUT отправляет только dirty fields через общий нормализующий контракт; пустой ключ не удаляет сохранённый. Успех — один inline receipt, без прежнего двойного toast и фиктивного сохранения mock-режима.
+2
View File
@@ -212,3 +212,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
| Блок | Референс | Реализованная схема |
| --- | --- | --- |
| 11Б.3.5.4.1 Evolution | [Settings 16](https://reui.io/preview/base/settings-16?ref=mcp) | Статус и свежесть → поля → сохранить / проверить → подтверждённый результат; удаление ключа → подтверждение → результат |
Завершён 11Б.3.5.4.2: live catalog consumers/sidebar/Filters без demo fallback, unknown/disabled/last snapshot и current details; 25 E2E, typecheck/профильный lint/build прошли. 3 community screenshots, 320/ПК просмотрены. Sidebar sync-effect baseline error и Filters unused warnings описаны в журнале. Далее 11Б.3.5.5 — HTTP/JWT catalog и приёмка Evolution.
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+28
View File
@@ -0,0 +1,28 @@
import {test,expect,type Page} from "@playwright/test"
import {evoSettings,evoCatalog} from "./fixtures/evobgp"
async function setup(page:Page,settings:unknown=evoSettings,catalog:unknown=evoCatalog){await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/auth/config"?{json:{required:false}}:path==="/api/evobgp/settings"?{json:settings}:path==="/api/evobgp/catalog"?{json:catalog}:{status:503,json:{error:"outside fixture"}})})}
const cases=[{path:"domains",mock:"youtube.com"},{path:"ip-ranges",mock:"8.8.8.0/24"},{path:"asns",mock:"AS15169"},{path:"communities",mock:"youtube-bypass"}]
for(const {path,mock} of cases)for(const state of ["unknown","disabled"] as const)test("Evolution catalog: "+path+"/"+state+" never shows mock rows or confirmed zero",async({page})=>{
await setup(page,state==="unknown"?{}:{...evoSettings,enabled:false});await page.goto("/"+path)
await expect(page.getByText(state==="unknown"?"Настройки EvoBGP неизвестны":"Интеграция EvoBGP выключена",{exact:true})).toBeVisible()
await expect(page.getByText("Данные каталога не получены",{exact:true})).toBeVisible()
await expect(page.getByText(mock,{exact:true})).toHaveCount(0)
await expect(page.getByRole("row")).toHaveCount(0)
if(path!=="communities")await expect(page.getByText("Количество: —",{exact:true})).toBeVisible()
await expect(page.getByRole("button",{name:/^Добавить/})).toHaveCount(0)
})
test("Evolution catalog: known empty differs from unread and permits refresh",async({page})=>{await setup(page,evoSettings,{...evoCatalog,domains:[]});await page.goto("/domains");await expect(page.getByText("Нет доменов",{exact:true})).toBeVisible();await expect(page.getByText("0 доменов",{exact:true})).toBeVisible();await expect(page.getByText("В подтверждённом каталоге нет записей этого типа",{exact:true})).toBeVisible();await expect(page.getByText("Данные каталога не получены",{exact:true})).toHaveCount(0)})
test("Evolution catalog: malformed initial response stays unread",async({page})=>{await setup(page,evoSettings,{});await page.goto("/domains");await expect(page.getByText("Каталог EvoBGP недоступен",{exact:true})).toBeVisible();await expect(page.getByText("Данные каталога не получены",{exact:true})).toBeVisible();await expect(page.getByText("0 доменов",{exact:true})).toHaveCount(0)})
test("Evolution catalog: refresh error preserves search, rows and freshness",async({page})=>{await setup(page);let fail=false;await page.route("**/api/evobgp/catalog",r=>r.fulfill(fail?{status:503,json:{error:"catalog down"}}:{json:evoCatalog}));await page.goto("/domains");await expect(page.getByText("kept.example.test",{exact:true})).toBeVisible();const stamp=page.getByRole("status").filter({hasText:/^Каталог EvoBGP получен:/});const before=await stamp.textContent();await page.getByRole("textbox",{name:"Поиск по домену…"}).fill("kept");fail=true;await page.getByRole("button",{name:"Обновить каталог EvoBGP",exact:true}).click();await expect(page.getByText("Каталог не обновлён; показаны последние данные",{exact:true})).toBeVisible();await expect(page.getByText("kept.example.test",{exact:true})).toBeVisible();await expect(stamp).toHaveText(before!);await expect(page.getByRole("textbox",{name:"Поиск по домену…"})).toHaveValue("kept")})
test("Evolution catalog: disabled can reload settings to discover enabled integration",async({page})=>{let enabled=false;await setup(page);await page.route("**/api/evobgp/settings",r=>r.fulfill({json:{...evoSettings,enabled}}));await page.goto("/domains");await expect(page.getByText("Интеграция EvoBGP выключена",{exact:true})).toBeVisible();enabled=true;await page.getByRole("button",{name:"Обновить каталог EvoBGP",exact:true}).click();await expect(page.getByText("kept.example.test",{exact:true})).toBeVisible()})
test("Evolution catalog: mock mode is explicit and does not read integration",async({page})=>{await page.addInitScript(()=>localStorage.setItem("routerlists:data-source","mock"));await setup(page);let requests=0;page.on("request",r=>{if(new URL(r.url()).pathname.startsWith("/api/evobgp/"))requests++});await page.goto("/asns");await expect(page.getByText("Демонстрационный каталог",{exact:true})).toBeVisible();await expect(page.getByText("AS15169",{exact:true})).toBeVisible();expect(requests).toBe(0)})
test("Evolution catalog: reader without admin cannot load integration or see demo data",async({page})=>{
const token="header."+Buffer.from(JSON.stringify({sub:"reader",apps:["mm"],permissions:["mm:dashboard:read","mm:filters:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture"
await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);await setup(page);await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let reads=0;page.on("request",r=>{if(new URL(r.url()).pathname.startsWith("/api/evobgp/"))reads++});await page.goto("/domains");await expect(page.getByText("Нет доступа к каталогу EvoBGP",{exact:true})).toBeVisible();await expect(page.getByRole("row")).toHaveCount(0);await expect(page.getByRole("button",{name:"Обновить каталог EvoBGP",exact:true})).toHaveCount(0);expect(reads).toBe(0)
})
test("Evolution catalog: selected community details follow refreshed snapshot",async({page})=>{
const community={id:"c",value:"64512:1",name:"Selected community",description:"",type:"custom",filterIds:[],serverCount:0,prefixCount:4,action:"permit",enabled:true};let prefixes=4;await setup(page);await page.route("**/api/evobgp/catalog",r=>r.fulfill({json:{...evoCatalog,communities:[{...community,prefixCount:prefixes}]}}));await page.goto("/communities");await page.getByRole("row").filter({hasText:"Selected community"}).click();const detail=page.getByText("Маршрутов",{exact:true}).locator("..");await expect(detail.getByText("4",{exact:true})).toBeVisible();prefixes=9;await page.getByRole("button",{name:"Обновить каталог EvoBGP",exact:true}).click();await expect(detail.getByText("9",{exact:true})).toBeVisible();await expect(detail.getByText("4",{exact:true})).toHaveCount(0)
})
for(const width of [1440,390,320])test("Evolution catalog: communities adaptive/"+width,async({page})=>{
await page.setViewportSize({width,height:1000});await setup(page,evoSettings,{});await page.goto("/communities");await expect(page.getByText("Данные каталога не получены",{exact:true})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true);await page.screenshot({path:"docs/ux-ui/screens/evolution-communities-"+width+".png",fullPage:true,animations:"disabled"})
})