diff --git a/app/(main)/asns/page.tsx b/app/(main)/asns/page.tsx index 74521dbc..c5338203 100644 --- a/app/(main)/asns/page.tsx +++ b/app/(main)/asns/page.tsx @@ -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() {
+ actions={mode === "live" ? null : <> @@ -60,32 +56,21 @@ export default function AsnsPage() {

Автономные системы — источники маршрутов для BGP-фильтров

- {useEvoCatalog && ( -

- {loading && } - {error - ? `EvoBGP: ${error}` - : snapshot?.fetchedAt - ? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}` - : loading ? "Загрузка EvoBGP…" : "EvoBGP"} -

- )} +
- + emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined} + /> : } diff --git a/app/(main)/communities/page.tsx b/app/(main)/communities/page.tsx index a6d9b05d..898bb1b4 100644 --- a/app/(main)/communities/page.tsx +++ b/app/(main)/communities/page.tsx @@ -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 +} +function CommunitiesContent() { + const { mode, snapshot, loading, known } = useEvoCatalogView() const [search, setSearch] = useState("") const [typeFilter, setTypeFilter] = useState("all") const [copied, setCopied] = useState(null) - const [selected, setSelected] = useState(null) + const [selectedId, setSelectedId] = useState(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() {
+ actions={mode === "live" ? null : } @@ -147,19 +144,7 @@ export default function CommunitiesPage() {
- {useEvoCatalog && ( -

- {loading && } - {error - ? `EvoBGP: ${error}` - : snapshot?.fetchedAt - ? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}` - : loading ? "Загрузка EvoBGP…" : "EvoBGP"} -

- )} + , 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: , 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: , 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: , iconClassName: "text-primary", }, ]} /> -
+
{/* ── main table ── */}
{/* toolbar */} @@ -204,7 +189,7 @@ export default function CommunitiesPage() { setSearch(e.target.value)} />
-
+
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
@@ -234,8 +219,8 @@ export default function CommunitiesPage() { description={selected.name} headerRight={
- - + +
} contentClassName="flex flex-col gap-4 text-sm px-5 pb-5" diff --git a/app/(main)/domains/page.tsx b/app/(main)/domains/page.tsx index f41b7c61..e3b43493 100644 --- a/app/(main)/domains/page.tsx +++ b/app/(main)/domains/page.tsx @@ -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() {
+ actions={mode === "live" ? null : <> @@ -58,34 +54,23 @@ export default function DomainsPage() {

Домены

- Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik + Домены из каталога для BGP-фильтрации

- {useEvoCatalog && ( -

- {loading && } - {error - ? `EvoBGP: ${error}` - : snapshot?.fetchedAt - ? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}` - : loading ? "Загрузка EvoBGP…" : "EvoBGP"} -

- )} +
- + emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined} + /> : }
diff --git a/app/(main)/filters/page.tsx b/app/(main)/filters/page.tsx index 9a8817a8..eda9eb2b 100644 --- a/app/(main)/filters/page.tsx +++ b/app/(main)/filters/page.tsx @@ -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 = {} - 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([]) /** GRE с роутера только по запрошенным serverId (текущий + цели копирования) */ diff --git a/app/(main)/ip-ranges/page.tsx b/app/(main)/ip-ranges/page.tsx index bf3ae038..140e1d8e 100644 --- a/app/(main)/ip-ranges/page.tsx +++ b/app/(main)/ip-ranges/page.tsx @@ -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() {
+ actions={mode === "live" ? null : <> @@ -59,34 +55,23 @@ export default function IpRangesPage() {

IP-диапазоны

- CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам + CIDR-блоки из каталога для сетевых фильтров

- {useEvoCatalog && ( -

- {loading && } - {error - ? `EvoBGP: ${error}` - : snapshot?.fetchedAt - ? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}` - : loading ? "Загрузка EvoBGP…" : "EvoBGP"} -

- )} +
- + emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined} + /> : }
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 6175b4a7..e5e84218 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -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) { 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) { 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 ( diff --git a/components/data-grids/asns-data-grid.tsx b/components/data-grids/asns-data-grid.tsx index 51f481b0..2be3be52 100644 --- a/components/data-grids/asns-data-grid.tsx +++ b/components/data-grids/asns-data-grid.tsx @@ -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[]>( () => [ { @@ -133,7 +134,7 @@ function AsnsDataGrid({ asns, isLoading, pagination = false }: AsnsDataGridProps } title="Нет ASN" - description="Добавьте автономные системы или импортируйте каталог" + description={emptyDescription ?? "Добавьте автономные системы или импортируйте каталог"} className="border-0 py-12" /> } diff --git a/components/data-grids/domains-data-grid.tsx b/components/data-grids/domains-data-grid.tsx index fc9d3ea7..03329955 100644 --- a/components/data-grids/domains-data-grid.tsx +++ b/components/data-grids/domains-data-grid.tsx @@ -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[]>( () => [ { @@ -142,7 +143,7 @@ function DomainsDataGrid({ domains, isLoading, pagination = false }: DomainsData } title="Нет доменов" - description="Добавьте домены или импортируйте каталог" + description={emptyDescription ?? "Добавьте домены или импортируйте каталог"} className="border-0 py-12" /> } diff --git a/components/data-grids/ip-ranges-data-grid.tsx b/components/data-grids/ip-ranges-data-grid.tsx index ba5e1504..f5554735 100644 --- a/components/data-grids/ip-ranges-data-grid.tsx +++ b/components/data-grids/ip-ranges-data-grid.tsx @@ -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[]>( () => [ { @@ -144,7 +145,7 @@ function IpRangesDataGrid({ ipRanges, isLoading, pagination = false }: IpRangesD } title="Нет IP-диапазонов" - description="Добавьте CIDR-блоки или импортируйте каталог" + description={emptyDescription ?? "Добавьте CIDR-блоки или импортируйте каталог"} className="border-0 py-12" /> } diff --git a/components/settings/evobgp-catalog-status.tsx b/components/settings/evobgp-catalog-status.tsx new file mode 100644 index 00000000..e5e6f18e --- /dev/null +++ b/components/settings/evobgp-catalog-status.tsx @@ -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

Демонстрационный каталог

+ 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
+ {reason?

{reason}

:null} + {snapshot?

Каталог EvoBGP получен: {evo.catalogReceivedAt?new Date(evo.catalogReceivedAt).toLocaleString("ru-RU"):"—"}{evo.loading?" · обновление…":""}

:null} + {error?:null} + {allowed?
Настройки → EvoBGP
:null} +
+} +export function EvoBgpCatalogUnavailable(){return } diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index c5c3703f..8ad39cf2 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 59c07e12..384bf8ab 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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-режима. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index fbd1a36e..8b316fea 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -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. diff --git a/docs/ux-ui/screens/evolution-communities-1440.png b/docs/ux-ui/screens/evolution-communities-1440.png new file mode 100644 index 00000000..fd8c6530 Binary files /dev/null and b/docs/ux-ui/screens/evolution-communities-1440.png differ diff --git a/docs/ux-ui/screens/evolution-communities-320.png b/docs/ux-ui/screens/evolution-communities-320.png new file mode 100644 index 00000000..f1339847 Binary files /dev/null and b/docs/ux-ui/screens/evolution-communities-320.png differ diff --git a/docs/ux-ui/screens/evolution-communities-390.png b/docs/ux-ui/screens/evolution-communities-390.png new file mode 100644 index 00000000..71960fdb Binary files /dev/null and b/docs/ux-ui/screens/evolution-communities-390.png differ diff --git a/tests/e2e/evobgp-consumers.spec.ts b/tests/e2e/evobgp-consumers.spec.ts new file mode 100644 index 00000000..d9a27b01 --- /dev/null +++ b/tests/e2e/evobgp-consumers.spec.ts @@ -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"}) +})