fix(evolution): разделить состояния настроек и каталога и защитить контекст
This commit is contained in:
1 parent
554dba55ec
commit
9db1471b2c
11 files changed
+158
-202
No files matched your search
+80
-197
@@ -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<void>
|
||||
saveSettings: (patch: EvoBgpSavePayload) => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
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<void>;saveSettings:(patch:EvoBgpSavePayload)=>Promise<void>;refresh:()=>Promise<void>
|
||||
testConnection:(draft?:EvoBgpTestDraft)=>Promise<{ok:boolean;message:string}>
|
||||
}
|
||||
|
||||
const EvoBgpContext = createContext<EvoBgpContextValue | null>(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<EvoBgpCatalogSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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<unknown>(
|
||||
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<EvoBgpSettingsDto>(
|
||||
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<EvoBgpSettingsDto>(
|
||||
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<unknown>(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 <EvoBgpContext.Provider value={value}>{children}</EvoBgpContext.Provider>
|
||||
}
|
||||
|
||||
export function useEvoBGP(): EvoBgpContextValue {
|
||||
const ctx = useContext(EvoBgpContext)
|
||||
if (!ctx) throw new Error("useEvoBGP must be used within EvoBGPProvider")
|
||||
return ctx
|
||||
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}
|
||||
Reference in new issue
Block a user