diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx index c3666c1e..a25b6866 100644 --- a/app/(main)/settings/page.tsx +++ b/app/(main)/settings/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link" import { PageHeader } from "@/components/page-header" import { FormToggle } from "@/components/form-kit" import { FileImportDialog } from "@/components/file-import-dialog" +import { EvoBgpDataStatus } from "@/components/settings/evobgp-data-status" import { OpsPanel } from "@/components/ops-panel" import { Frame, FramePanel } from "@/components/reui/frame" import { Button, buttonVariants } from "@/components/ui/button" @@ -515,6 +516,7 @@ export default function SettingsPage() { // ── EvoBGP ── if (section === "EvoBGP") return (
+ {(mode !== "live" || backendStatus !== true) && ( @@ -689,9 +691,6 @@ export default function SettingsPage() { {evoTestResult.message}

)} - {evo.error && ( -

{evo.error}

- )}
) diff --git a/components/settings/evobgp-data-status.tsx b/components/settings/evobgp-data-status.tsx new file mode 100644 index 00000000..68e87856 --- /dev/null +++ b/components/settings/evobgp-data-status.tsx @@ -0,0 +1,22 @@ +"use client" +import {useEvoBGP} from "@/lib/evobgp-context" +import {useDataSource} from "@/lib/data-source" +import {can} from "@/lib/auth" +import {Alert,AlertDescription,AlertTitle} from "@/components/reui/alert" +import {Badge} from "@/components/reui/badge" +import {Button} from "@/components/ui/button" +import {AlertCircleIcon} from "lucide-react" +const date=(value:string|null)=>value?new Date(value).toLocaleString("ru-RU"):"—" +export function EvoBgpDataStatus(){ + const evo=useEvoBGP(),{mode,backendStatus}=useDataSource() + return
+
Интеграция: {evo.settingsLoaded?evo.enabled?"включена":"выключена":"—"}Ключ: {evo.settingsLoaded?evo.secretConfigured?"сохранён":"не сохранён":"—"}
+

{evo.settingsLoaded?"Настройки EvoBGP получены: "+date(evo.settingsReceivedAt):evo.settingsLoading?"Загрузка настроек EvoBGP…":"Настройки EvoBGP не получены"}

+ {evo.settingsLoaded&&evo.settingsLoading?

Обновление настроек EvoBGP…

:null} + {evo.settingsError?:null} +

{evo.snapshot?"Каталог EvoBGP получен: "+date(evo.catalogReceivedAt):evo.loading?"Загрузка каталога EvoBGP…":evo.settingsLoaded&&!evo.enabled?"Каталог EvoBGP отключён в сохранённых настройках":"Каталог EvoBGP не получен"}

+ {evo.snapshot&&evo.loading?

Обновление каталога EvoBGP…

:null} + {evo.catalogError?:null} + +
+} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 03f65770..ed1edd5a 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -274,3 +274,5 @@ REST запрос → ожидание → подтверждённый output / Завершён 11Б.3.5.1 — contracts/Evolution strict settings/save/test API: 3 unit, contracts build/typecheck/lint прошли. Новый PUT helper пока не подключён (backend старый receipt). Далее 11Б.3.5.2 backend selective save/подтверждение, затем provider и форма. Завершён 11Б.3.5.2: Evolution backend selective RETURNING save/public receipt и проверка items upstream/test; DI HTTP/JWT, backend build/lint прошли. Старый provider совместим с публичными полями, ещё не мигрирован. Далее 11Б.3.5.3 — provider known/error/time/source; затем форма и upstream catalog validation. + +Завершён 11Б.3.5.3.1: Evolution provider known/error/time/readers/source/strict receipts, статус Settings; 8 provider (один selector повторён), 2 прежних и 6 source E2E, lint/typecheck/production build прошли. Форма/consumer mocks ещё не приняты. Далее 11Б.3.5.3.2 — upstream catalog strict/unknown counts; затем 11Б.3.5.4 форма/consumers. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 1f1d6b22..33036c1e 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.2; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.3.1; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11Б.3.5.3.1 — Evolution provider и свежесть (2026-10-03) + +- Provider подключил strict settings/save/test helpers. GET error/malformed не становится settingsLoaded; settings и catalog имеют независимые loading/error/browser receivedAt. Catalog failure сохраняет последнее snapshot/time; отключение/смена сохранённого URL/secret flag и запись ключа инвалидируют прежний каталог. Save заканчивается по receipt до независимого запроса каталога. +- Контекст привязан к mode/backend и отдельному immutable token каждой смены; children приложения не перемонтируются. Reader revision/abort, active guards и единый save/test lock исключают публикацию старого источника. Finish сверяет конкретную session, чтобы старый callback не снимал новый lock. Provider API abort не доказывает отмену backend. +- В Settings добавлен статус с unknown/false, свежестью и раздельными ошибками; исходные поля/dirty/effects ещё требуют миграции. Локальный ref session заменил первоначальную mutable useMemo session после замечаний ESLint; итоговый lint и typecheck прошли. +- 8 provider E2E: 7 прошли сразу, malformed PUT selector уточнён для inline+toast старой формы и повтор прошёл. 2 предыдущих catalog/test E2E и 6 source E2E Settings/Catalog/Save (success/error) прошли; source меняется внутри текущего layout через General/backend input. Production frontend/contracts build прошёл. +- Это приёмка provider данных, не всей формы/настроек. Старые caller toast/error/dirty guards остаются до 11Б.3.5.4; live catalog consumers ещё могут показывать mock при отключённой/unknown интеграции. Реальный Evolution/PG/Portal не проверен. +- Далее 11Б.3.5.3.2: strict upstream catalog mapper и nullable unknown ASN/community prefix counts (сейчас backend превращает malformed lists/неизвестные числа в пустые массивы/0). Затем форма/consumers 11Б.3.5.4. + ## Блок 11Б.3.5.2 — backend Evolution save/test (2026-10-03) - Settings GET/PUT/test вынесены в DI plugin. PUT пишет только переданные нормализованные поля, RETURNING подтверждает их фактические значения, включая apiKey внутри backend; ответ отдаёт только literal ok/public flags/savedFields, без key echo. Singleton insert устойчив к конфликту. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index f8f91e5e..cedb63c3 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -202,3 +202,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 11Б.3.5.1 — contracts/Evolution strict settings/save/test API: 3 unit, contracts build/typecheck/lint прошли. Новый PUT helper пока не подключён (backend старый receipt). Далее 11Б.3.5.2 backend selective save/подтверждение, затем provider и форма. Завершён 11Б.3.5.2: Evolution backend selective RETURNING save/public receipt и проверка items upstream/test; DI HTTP/JWT, backend build/lint прошли. Старый provider совместим с публичными полями, ещё не мигрирован. Далее 11Б.3.5.3 — provider known/error/time/source; затем форма и upstream catalog validation. + +Завершён 11Б.3.5.3.1: Evolution provider known/error/time/readers/source/strict receipts, статус Settings; 8 provider (один selector повторён), 2 прежних и 6 source E2E, lint/typecheck/production build прошли. Форма/consumer mocks ещё не приняты. Далее 11Б.3.5.3.2 — upstream catalog strict/unknown counts; затем 11Б.3.5.4 форма/consumers. diff --git a/lib/evobgp-context.tsx b/lib/evobgp-context.tsx index 86296e43..18c3c97c 100644 --- a/lib/evobgp-context.tsx +++ b/lib/evobgp-context.tsx @@ -1,19 +1,13 @@ "use client" - -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react" -import { useDataSource } from "@/lib/data-source" -import type { Domain, IpRange, Asn } from "@/lib/data" -import { confirmEvoBgpConnection } from "@/lib/evobgp-test-receipt" -import { parseEvoBgpCatalog } from "@/lib/evobgp-catalog" -import { ApiClientError, requestJson } from "@/shared/api/http-client" - +import {createContext,useCallback,useContext,useEffect,useMemo,useRef,useState} from "react" +import {useDataSource} from "@/lib/data-source" +import {can} from "@/lib/auth" +import type {Domain,IpRange,Asn} from "@/lib/data" +import type {EvoBgpSettingsDto,EvoBgpSavePayload,EvoBgpTestDraft} from "@mmapp/contracts/evobgp" +export type {EvoBgpSettingsDto,EvoBgpSavePayload,EvoBgpTestDraft} +import {getEvoBgpSettings,putEvoBgpSettings,testEvoBgpConnection} from "@/shared/api/evobgp-settings" +import {parseEvoBgpCatalog} from "@/lib/evobgp-catalog" +import {requestJson} from "@/shared/api/http-client" export interface EvoBgpCommunityRow { id: string value: string @@ -37,189 +31,78 @@ export interface EvoBgpCatalogSnapshot { communities: EvoBgpCommunityRow[] } -export interface EvoBgpSettingsDto { - baseUrl: string - enabled: boolean - secretConfigured: boolean -} - -/** undefined — не менять ключ; null или очистка через отдельное сохранение */ -export type EvoBgpSavePayload = { - baseUrl?: string - enabled?: boolean - apiKey?: string | null -} - -/** Черновик для POST /evobgp/test: пустой объект — всё из БД */ -export type EvoBgpTestDraft = { baseUrl?: string; apiKey?: string } interface EvoBgpContextValue { - baseUrl: string - enabled: boolean - secretConfigured: boolean - settingsLoaded: boolean - snapshot: EvoBgpCatalogSnapshot | null - loading: boolean - error: string | null - loadSettings: () => Promise - saveSettings: (patch: EvoBgpSavePayload) => Promise - refresh: () => Promise - testConnection: (draft?: EvoBgpTestDraft) => Promise<{ ok: boolean; message: string }> + baseUrl:string;enabled:boolean;secretConfigured:boolean;settingsLoaded:boolean + settingsLoading:boolean;settingsError:string|null;settingsReceivedAt:string|null;sourceKey:string + snapshot:EvoBgpCatalogSnapshot|null;loading:boolean;error:string|null;catalogError:string|null;catalogReceivedAt:string|null;operationBusy:boolean + loadSettings:()=>Promise;saveSettings:(patch:EvoBgpSavePayload)=>Promise;refresh:()=>Promise + testConnection:(draft?:EvoBgpTestDraft)=>Promise<{ok:boolean;message:string}> } - -const EvoBgpContext = createContext(null) - -function errorMessage(e: unknown, fallback: string): string { - if (e instanceof ApiClientError) return e.message || fallback - if (e instanceof Error) return e.message || fallback - return fallback -} - -export function EvoBGPProvider({ children }: { children: React.ReactNode }) { - const { mode, backendUrl, backendStatus } = useDataSource() - const [baseUrl, setBaseUrlState] = useState("") - const [enabled, setEnabledState] = useState(false) - const [secretConfigured, setSecretConfigured] = useState(false) - const [settingsLoaded, setSettingsLoaded] = useState(false) - const [snapshot, setSnapshot] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - const pullCatalog = useCallback( - async (catalogEnabled: boolean) => { - if (mode !== "live" || backendStatus !== true || !catalogEnabled) { - setSnapshot(null) - setError(null) - return - } - setLoading(true) - setError(null) - try { - const data = await requestJson( - backendUrl, - "/api/evobgp/catalog", - { method: "POST" }, - ) - setSnapshot(parseEvoBgpCatalog(data)) - } catch (e) { - setSnapshot(null) - setError(errorMessage(e, "Ошибка загрузки")) - } finally { - setLoading(false) - } - }, - [mode, backendStatus, backendUrl], - ) - - const loadSettings = useCallback(async () => { - if (mode !== "live" || backendStatus !== true) { - setSettingsLoaded(false) - setSnapshot(null) - setError(null) - return - } - try { - const data = await requestJson( - backendUrl, - "/api/evobgp/settings", - ) - setBaseUrlState(data.baseUrl ?? "") - setEnabledState(Boolean(data.enabled)) - setSecretConfigured(Boolean(data.secretConfigured)) - setSettingsLoaded(true) - setError(null) - await pullCatalog(Boolean(data.enabled)) - } catch (e) { - setSettingsLoaded(true) - setError(errorMessage(e, "Не удалось загрузить настройки EvoBGP")) - } - }, [mode, backendStatus, backendUrl, pullCatalog]) - - useEffect(() => { - queueMicrotask(() => { - void loadSettings() - }) - }, [loadSettings]) - - const saveSettings = useCallback( - async (patch: EvoBgpSavePayload) => { - const data = await requestJson( - backendUrl, - "/api/evobgp/settings", - { - method: "PUT", - body: JSON.stringify(patch), - }, - ) - const nextEnabled = Boolean(data.enabled) - setBaseUrlState(data.baseUrl ?? "") - setEnabledState(nextEnabled) - setSecretConfigured(Boolean(data.secretConfigured)) - setError(null) - // Каталог не должен ронять успех сохранения (401/502 на catalog ≠ «настройки не сохранились») - try { - await pullCatalog(nextEnabled) - } catch { - /* pullCatalog already sets error state */ - } - }, - [backendUrl, pullCatalog], - ) - - const refresh = useCallback(async () => { - await pullCatalog(enabled) - }, [enabled, pullCatalog]) - - const testConnection = useCallback( - async (draft?: EvoBgpTestDraft) => { - try { - const receipt = await requestJson(backendUrl, "/api/evobgp/test", { - method: "POST", - body: JSON.stringify(draft ?? {}), - }) - confirmEvoBgpConnection(receipt) - return { ok: true, message: "Соединение с EvoBGP установлено" } - } catch (e) { - return { ok: false, message: errorMessage(e, "Ошибка") } - } - }, - [backendUrl], - ) - - const value = useMemo( - () => ({ - baseUrl, - enabled, - secretConfigured, - settingsLoaded, - snapshot, - loading, - error, - loadSettings, - saveSettings, - refresh, - testConnection, - }), - [ - baseUrl, - enabled, - secretConfigured, - settingsLoaded, - snapshot, - loading, - error, - loadSettings, - saveSettings, - refresh, - testConnection, - ], - ) - - return {children} -} - -export function useEvoBGP(): EvoBgpContextValue { - const ctx = useContext(EvoBgpContext) - if (!ctx) throw new Error("useEvoBGP must be used within EvoBGPProvider") - return ctx +const EvoBgpContext=createContext(null) +type State={token:symbol;sourceKey:string;settings:EvoBgpSettingsDto|null;settingsLoading:boolean;settingsError:string|null;settingsReceivedAt:string|null;snapshot:EvoBgpCatalogSnapshot|null;loading:boolean;catalogError:string|null;catalogReceivedAt:string|null;operationBusy:boolean} +const empty=(sourceKey:string,token:symbol):State=>({token,sourceKey,settings:null,settingsLoading:false,settingsError:null,settingsReceivedAt:null,snapshot:null,loading:false,catalogError:null,catalogReceivedAt:null,operationBusy:false}) +type Session={token:symbol;key:string;active:boolean;settingsRevision:number;catalogRevision:number;settingsReader?:AbortController;catalogReader?:AbortController;operation?:AbortController;mutating:boolean;knownSettings:EvoBgpSettingsDto|null} +const message=(error:unknown)=>error instanceof Error?error.message:"Не удалось получить данные EvoBGP" +export function EvoBGPProvider({children}:{children:React.ReactNode}) { + const {mode,backendUrl,backendStatus,prefsHydrated}=useDataSource() + const sourceKey=JSON.stringify([mode,backendUrl]) + const sourceToken=useMemo(()=>Symbol(sourceKey),[sourceKey]) + const session=useRef({token:sourceToken,key:sourceKey,active:true,settingsRevision:0,catalogRevision:0,mutating:false,knownSettings:null}) + const [stored,setStored]=useState(()=>empty(sourceKey,sourceToken)) + const state=stored.token===sourceToken?stored:empty(sourceKey,sourceToken) + const eligible=prefsHydrated&&mode==="live"&&backendStatus===true&&can("mm:settings:admin") + useEffect(()=>{const scope:Session={token:sourceToken,key:sourceKey,active:true,settingsRevision:0,catalogRevision:0,mutating:false,knownSettings:null};session.current=scope;return()=>{scope.active=false;scope.settingsReader?.abort();scope.catalogReader?.abort();scope.operation?.abort()}},[sourceKey,sourceToken]) + const update=useCallback((patch:Partial)=>{const scope=session.current;if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active)return;setStored(current=>({...(current.token===sourceToken?current:empty(scope.key,sourceToken)),...patch}))},[session,sourceKey,sourceToken]) + const acceptSettings=useCallback((data:EvoBgpSettingsDto)=>{const scope=session.current;if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active)return; + const previous=scope.knownSettings + const changed=previous&&(previous.baseUrl!==data.baseUrl||previous.enabled!==data.enabled||previous.secretConfigured!==data.secretConfigured) + scope.knownSettings=data + update({settings:data,settingsError:null,settingsReceivedAt:new Date().toISOString(),...(changed?{snapshot:null,catalogError:null,catalogReceivedAt:null}:{})}) + },[session,sourceKey,sourceToken,update]) + const pullCatalog=useCallback(async(catalogEnabled:boolean)=>{const scope=session.current; + if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active||scope.mutating||!eligible)return + if(!catalogEnabled){scope.catalogRevision++;scope.catalogReader?.abort();update({snapshot:null,catalogError:null,catalogReceivedAt:null,loading:false});return} + const revision=++scope.catalogRevision;scope.catalogReader?.abort();const controller=new AbortController();scope.catalogReader=controller + const current=()=>scope.active&&scope.catalogRevision===revision&&!controller.signal.aborted + update({loading:true}) + try{const raw=await requestJson(backendUrl,"/api/evobgp/catalog",{method:"POST",signal:AbortSignal.any([controller.signal,AbortSignal.timeout(30_000)])});const snapshot=parseEvoBgpCatalog(raw);if(current())update({snapshot,catalogError:null,catalogReceivedAt:new Date().toISOString()})} + catch(error){if(current())update({catalogError:message(error)})} + finally{if(current())update({loading:false})} + },[backendUrl,eligible,session,sourceKey,sourceToken,update]) + const loadSettings=useCallback(async()=>{const scope=session.current; + if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active||scope.mutating)return + if(!eligible){if(prefsHydrated&&mode==="live")update({settingsError:!can("mm:settings:admin")?"Нет доступа к настройкам EvoBGP":backendStatus===false?"Backend EvoBGP недоступен":null});return} + const revision=++scope.settingsRevision;scope.settingsReader?.abort();const controller=new AbortController();scope.settingsReader=controller + const current=()=>scope.active&&scope.settingsRevision===revision&&!controller.signal.aborted + update({settingsLoading:true}) + try{const data=await getEvoBgpSettings(backendUrl,{signal:controller.signal});if(!current())return;acceptSettings(data);void pullCatalog(data.enabled)} + catch(error){if(current())update({settingsError:message(error)})} + finally{if(current())update({settingsLoading:false})} + },[acceptSettings,backendStatus,backendUrl,eligible,mode,prefsHydrated,pullCatalog,session,sourceKey,sourceToken,update]) + useEffect(()=>{queueMicrotask(()=>{void loadSettings()})},[loadSettings]) + const begin=useCallback(()=>{const scope=session.current; + if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active||scope.mutating||!eligible||!scope.knownSettings)throw new Error("Настройки EvoBGP ещё не подтверждены или действие недоступно") + scope.mutating=true;scope.settingsRevision++;scope.catalogRevision++;scope.settingsReader?.abort();scope.catalogReader?.abort();const controller=new AbortController();scope.operation=controller + update({operationBusy:true,settingsLoading:false,loading:false});return controller + },[eligible,session,sourceKey,sourceToken,update]) + const finish=useCallback((expected:Session)=>{const scope=session.current;if(scope!==expected||scope.token!==sourceToken||scope.key!==sourceKey||!scope.active)return;scope.mutating=false;update({operationBusy:false})},[session,sourceKey,sourceToken,update]) + const saveSettings=useCallback(async(patch:EvoBgpSavePayload)=>{const scope=session.current; + const controller=begin() + try{const data=await putEvoBgpSettings(backendUrl,patch,{signal:controller.signal});if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active)throw new Error("Контекст EvoBGP изменился") + acceptSettings({baseUrl:data.baseUrl,enabled:data.enabled,secretConfigured:data.secretConfigured}) + if(patch.apiKey!==undefined)update({snapshot:null,catalogError:null,catalogReceivedAt:null}) + }finally{finish(scope)} + // Saving is complete before any independent catalog request; its failure cannot cancel the receipt. + if(scope.active&&scope.knownSettings)void pullCatalog(scope.knownSettings.enabled) + },[acceptSettings,backendUrl,begin,finish,pullCatalog,session,sourceKey,sourceToken,update]) + const refresh=useCallback(async()=>{const scope=session.current;if(scope.token===sourceToken&&scope.key===sourceKey&&scope.knownSettings)await pullCatalog(scope.knownSettings.enabled)},[pullCatalog,session,sourceKey,sourceToken]) + const testConnection=useCallback(async(draft?:EvoBgpTestDraft)=>{const scope=session.current; + let started=false + try{const controller=begin();started=true;await testEvoBgpConnection(backendUrl,draft,{signal:controller.signal});if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active)return {ok:false,message:"Контекст EvoBGP изменился"};return {ok:true,message:"Соединение с EvoBGP установлено"}} + catch(error){return {ok:false,message:message(error)}}finally{if(started)finish(scope)} + },[backendUrl,begin,finish,session,sourceKey,sourceToken]) + const value:EvoBgpContextValue={baseUrl:state.settings?.baseUrl??"",enabled:state.settings?.enabled??false,secretConfigured:state.settings?.secretConfigured??false,settingsLoaded:!!state.settings,settingsLoading:state.settingsLoading,settingsError:state.settingsError,settingsReceivedAt:state.settingsReceivedAt,sourceKey,snapshot:state.snapshot,loading:state.loading,error:state.settingsError??state.catalogError,catalogError:state.catalogError,catalogReceivedAt:state.catalogReceivedAt,operationBusy:state.operationBusy,loadSettings,saveSettings,refresh,testConnection} + return {children} } +export function useEvoBGP():EvoBgpContextValue{const context=useContext(EvoBgpContext);if(!context)throw new Error("useEvoBGP must be used within EvoBGPProvider");return context} diff --git a/playwright.config.ts b/playwright.config.ts index d9e77f90..fac411bb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], + testIgnore: ["**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], fullyParallel: false, workers: 1, retries: 0, diff --git a/playwright.evobgp-context.config.ts b/playwright.evobgp-context.config.ts new file mode 100644 index 00000000..be0a5654 --- /dev/null +++ b/playwright.evobgp-context.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "@playwright/test" +import base from "./playwright.config" +const server = base.webServer +if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер") +export default defineConfig({ ...base, use: { ...base.use, baseURL: "http://127.0.0.1:3101" }, testIgnore: [], testMatch: "evobgp-context.spec.ts", webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local" } } }) diff --git a/tests/e2e/evobgp-context.spec.ts b/tests/e2e/evobgp-context.spec.ts new file mode 100644 index 00000000..06cf299d --- /dev/null +++ b/tests/e2e/evobgp-context.spec.ts @@ -0,0 +1,16 @@ +import {test,expect,type Page} from "@playwright/test" +import {evoSettings as settings,evoCatalog as catalog} from "./fixtures/evobgp" +const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,PUT,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"} +async function setup(page:Page){await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const url=new URL(r.request().url()),fresh=url.port==="8101";return r.fulfill(url.pathname==="/api/auth/config"?{headers,json:{required:false}}:url.pathname==="/api/evobgp/settings"?{headers,json:{...settings,baseUrl:fresh?"https://new.evo.test":settings.baseUrl}}:url.pathname==="/api/evobgp/catalog"?{headers,json:{...catalog,domains:[{...catalog.domains[0],domain:fresh?"fresh.example.test":"stale.example.test"}]}}:{headers,status:503,json:{error:"outside Evolution context"}})})} +for(const action of ["settings","catalog","save"] as const)for(const failed of [false,true])test("Evolution provider: старый backend/"+action+"/"+failed,async({page})=>{ + await setup(page);let started=false,completed=false,release!:()=>void;const gate=new Promise(resolve=>release=resolve) + const path=action==="catalog"?"/api/evobgp/catalog":"/api/evobgp/settings" + await page.route("**"+path,async r=>{if(new URL(r.request().url()).port!=="8000"||r.request().method()==="OPTIONS"||(action==="save"?r.request().method()!=="PUT":action==="settings"?r.request().method()!=="GET":false))return r.fallback();started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА"}}:{headers,json:action==="settings"?settings:action==="catalog"?{...catalog,domains:[{...catalog.domains[0],domain:"stale.example.test"}]}:{...settings,...r.request().postDataJSON(),ok:true,savedFields:Object.keys(r.request().postDataJSON())}})}catch{}finally{completed=true}}) + try{ + await page.goto("/settings") + if(action==="save"){await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue(settings.baseUrl);await page.getByPlaceholder("http://localhost:8080",{exact:true}).fill("https://old-write.evo.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click()} + await expect.poll(()=>started).toBe(true);if(action==="save")await page.getByRole("button",{name:"Общие",exact:true}).click() + const url=page.getByPlaceholder("http://localhost:8000",{exact:true});await url.fill("http://localhost:8101");await url.press("Enter");await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");release();await expect.poll(()=>completed).toBe(true) + await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");await expect(page.getByText("СТАРАЯ ОШИБКА",{exact:true})).toHaveCount(0);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("fresh.example.test",{exact:true})).toBeVisible();await expect(page.getByText("stale.example.test",{exact:true})).toHaveCount(0) + }finally{release()} +}) diff --git a/tests/e2e/evobgp-provider.spec.ts b/tests/e2e/evobgp-provider.spec.ts new file mode 100644 index 00000000..d86aa1f5 --- /dev/null +++ b/tests/e2e/evobgp-provider.spec.ts @@ -0,0 +1,16 @@ +import {test,expect,type Page} from "@playwright/test" +import {evoSettings as settings,evoCatalog as catalog} from "./fixtures/evobgp" +async function setup(page:Page){await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(p==="/api/auth/config"?{json:{required:false}}:p==="/api/evobgp/settings"?{json:settings}:p==="/api/evobgp/catalog"?{json:catalog}:{status:503,json:{error:"outside Evolution fixture"}})})} +async function open(page:Page){await page.goto("/settings");await page.getByRole("button",{name:"EvoBGP",exact:true}).click()} +const base=(page:Page)=>page.getByPlaceholder("http://localhost:8080",{exact:true}) +const settingsStamp=(page:Page)=>page.getByRole("status").filter({hasText:/^Настройки EvoBGP получены:/}) +const catalogStamp=(page:Page)=>page.getByRole("status").filter({hasText:/^Каталог EvoBGP получен:/}) +async function ready(page:Page){await expect(base(page)).toHaveValue(settings.baseUrl);await expect(catalogStamp(page)).toBeVisible();await expect(page.getByText("Обновление каталога EvoBGP…",{exact:true})).toHaveCount(0)} +test("Evolution provider: malformed settings остаются unknown",async({page})=>{await setup(page);await page.route("**/api/evobgp/settings",r=>r.fulfill({json:{}}));await open(page);await expect(page.getByText("Настройки EvoBGP неизвестны",{exact:true})).toBeVisible();await expect(page.getByText("Ключ: —",{exact:true})).toBeVisible();await expect(settingsStamp(page)).toHaveCount(0);await expect(base(page)).toHaveValue("")}) +test("Evolution provider: каталог не задерживает known настройки",async({page})=>{await setup(page);let started=false,release!:()=>void;const gate=new Promise(resolve=>release=resolve);await page.route("**/api/evobgp/catalog",async r=>{started=true;await gate;try{await r.fulfill({json:catalog})}catch{}});try{await open(page);await expect.poll(()=>started).toBe(true);await expect(base(page)).toHaveValue(settings.baseUrl);await expect(settingsStamp(page)).toBeVisible();await expect(page.getByText("Загрузка каталога EvoBGP…",{exact:true})).toBeVisible();release();await expect(catalogStamp(page)).toBeVisible()}finally{release()}}) +test("Evolution provider: ошибка settings GET сохраняет настройки, каталог и время",async({page})=>{await setup(page);let failed=false;await page.route("**/api/evobgp/settings",r=>r.fulfill(failed?{status:503,json:{error:"settings unavailable"}}:{json:settings}));await open(page);await ready(page);const before=await settingsStamp(page).textContent(),catBefore=await catalogStamp(page).textContent();failed=true;await page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true}).click();await expect(page.getByText("Настройки EvoBGP не обновлены",{exact:true})).toBeVisible();await expect(settingsStamp(page)).toHaveText(before!);await expect(catalogStamp(page)).toHaveText(catBefore!);await expect(base(page)).toHaveValue(settings.baseUrl)}) +test("Evolution provider: ошибка каталога сохраняет последний снимок и время",async({page})=>{await setup(page);let failed=false;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog unavailable"}}:{json:catalog}));await open(page);await ready(page);const before=await catalogStamp(page).textContent();failed=true;await page.getByRole("button",{name:"Обновить каталог",exact:true}).click();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();await expect(catalogStamp(page)).toHaveText(before!);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("kept.example.test",{exact:true})).toBeVisible()}) +test("Evolution provider: отключение подтверждённой интеграции очищает каталог",async({page})=>{await setup(page);let enabled=true,posts=0;await page.route("**/api/evobgp/settings",r=>r.fulfill({json:{...settings,enabled}}));await page.route("**/api/evobgp/catalog",r=>{posts++;return r.fulfill({json:catalog})});await open(page);await ready(page);const before=posts;enabled=false;await page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true}).click();await expect(page.getByText("Интеграция: выключена",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP отключён в сохранённых настройках",{exact:true})).toBeVisible();await expect(catalogStamp(page)).toHaveCount(0);expect(posts).toBe(before)}) +test("Evolution provider: сохранение не ждёт каталог и не отменяется его отказом",async({page})=>{await setup(page);let failed=false,writes=0;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog after save unavailable"}}:{json:catalog}));await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;failed=true;const patch=r.request().postDataJSON();return r.fulfill({json:{...settings,...patch,ok:true,savedFields:Object.keys(patch)}})});await open(page);await ready(page);await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();expect(writes).toBe(1)}) +test("Evolution provider: malformed PUT не подтверждает сохранение",async({page})=>{await setup(page);let writes=0;await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;return r.fulfill({json:{}})});await open(page);await ready(page);await base(page).fill("https://new.example.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.locator("#main-content").getByText("Сохранение EvoBGP не подтверждено сервером",{exact:true})).toBeVisible();await expect(base(page)).toHaveValue("https://new.example.test");expect(writes).toBe(1)}) +test("Evolution provider: права settings admin проверяются до GET",async({page})=>{const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:dashboard: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 open(page);await expect(page.getByText("Нет доступа к настройкам EvoBGP",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true})).toBeDisabled();expect(reads).toBe(0)}) diff --git a/tests/e2e/fixtures/evobgp.ts b/tests/e2e/fixtures/evobgp.ts new file mode 100644 index 00000000..51a1200f --- /dev/null +++ b/tests/e2e/fixtures/evobgp.ts @@ -0,0 +1,2 @@ +export const evoSettings={baseUrl:"https://evo.example.test",enabled:true,secretConfigured:true} +export const evoCatalog={fetchedAt:"2026-10-03T00:00:00Z",modules:[],domains:[{id:"fixture-domain",domain:"kept.example.test",resolvedIp:"192.0.2.1",asn:"AS64512",purpose:"fixture",filter:"fixture",updated:"сейчас",enabled:true}],ipRanges:[],asns:[],communities:[]}