109 lines
9.9 KiB
TypeScript
109 lines
9.9 KiB
TypeScript
"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 | null
|
|
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<void>;saveSettings:(patch:EvoBgpSavePayload)=>Promise<void>;refresh:()=>Promise<void>
|
|
testConnection:(draft?:EvoBgpTestDraft)=>Promise<{ok:boolean;message:string}>
|
|
}
|
|
const EvoBgpContext=createContext<EvoBgpContextValue|null>(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<Session>({token:sourceToken,key:sourceKey,active:true,settingsRevision:0,catalogRevision:0,mutating:false,knownSettings:null})
|
|
const [stored,setStored]=useState<State>(()=>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<State>)=>{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<unknown>(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 <EvoBgpContext.Provider value={value}>{children}</EvoBgpContext.Provider>
|
|
}
|
|
export function useEvoBGP():EvoBgpContextValue{const context=useContext(EvoBgpContext);if(!context)throw new Error("useEvoBGP must be used within EvoBGPProvider");return context}
|