"use 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 name: string description: string type: "standard" | "no-export" | "no-advertise" | "local-as" | "custom" filterIds: string[] serverCount: number prefixCount: number action: "permit" | "deny" | "local-pref" | "metric" actionValue?: number enabled: boolean } export interface EvoBgpCatalogSnapshot { fetchedAt: string modules: { id: string; name: string; type: string }[] domains: Domain[] ipRanges: IpRange[] asns: Asn[] communities: EvoBgpCommunityRow[] } interface EvoBgpContextValue { 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) 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}