{evo.settingsLoaded?"Настройки EvoBGP получены: "+date(evo.settingsReceivedAt):evo.settingsLoading?"Загрузка настроек EvoBGP…":"Настройки EvoBGP не получены"}
+ {evo.settingsLoaded&&evo.settingsLoading?
Обновление настроек EvoBGP…
:null}
+ {evo.settingsError?{evo.settingsLoaded?"Настройки EvoBGP не обновлены":"Настройки EvoBGP неизвестны"}{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?{evo.snapshot?"Каталог EvoBGP не обновлён; показаны последние данные":"Каталог EvoBGP неизвестен"}{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:[]}