Files
MikrotikManager/lib/evobgp-context.tsx
T

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}