fix(evolution): разделить состояния настроек и каталога и защитить контекст

This commit is contained in:
Denozordec committed 2026-10-03 13:42:00 +07:00
1 parent 554dba55ec
commit 9db1471b2c
11 files changed
+158 -202

No files matched your search

+80 -197
View File
@@ -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}