fix(evolution): разделить состояния настроек и каталога и защитить контекст
This commit is contained in:
1 parent
554dba55ec
commit
9db1471b2c
11 files changed
+158
-202
No files matched your search
@@ -5,6 +5,7 @@ import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { EvoBgpDataStatus } from "@/components/settings/evobgp-data-status"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -515,6 +516,7 @@ export default function SettingsPage() {
|
||||
// ── EvoBGP ──
|
||||
if (section === "EvoBGP") return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<EvoBgpDataStatus />
|
||||
{(mode !== "live" || backendStatus !== true) && (
|
||||
<Frame dense className="w-full">
|
||||
<FramePanel className="px-4 py-4">
|
||||
@@ -689,9 +691,6 @@ export default function SettingsPage() {
|
||||
{evoTestResult.message}
|
||||
</p>
|
||||
)}
|
||||
{evo.error && (
|
||||
<p className="text-xs text-destructive">{evo.error}</p>
|
||||
)}
|
||||
</OpsPanel>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
import {useEvoBGP} from "@/lib/evobgp-context"
|
||||
import {useDataSource} from "@/lib/data-source"
|
||||
import {can} from "@/lib/auth"
|
||||
import {Alert,AlertDescription,AlertTitle} from "@/components/reui/alert"
|
||||
import {Badge} from "@/components/reui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {AlertCircleIcon} from "lucide-react"
|
||||
const date=(value:string|null)=>value?new Date(value).toLocaleString("ru-RU"):"—"
|
||||
export function EvoBgpDataStatus(){
|
||||
const evo=useEvoBGP(),{mode,backendStatus}=useDataSource()
|
||||
return <div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2"><Badge variant="secondary">Интеграция: {evo.settingsLoaded?evo.enabled?"включена":"выключена":"—"}</Badge><Badge variant="secondary">Ключ: {evo.settingsLoaded?evo.secretConfigured?"сохранён":"не сохранён":"—"}</Badge></div>
|
||||
<p role="status" className="text-xs text-muted-foreground">{evo.settingsLoaded?"Настройки EvoBGP получены: "+date(evo.settingsReceivedAt):evo.settingsLoading?"Загрузка настроек EvoBGP…":"Настройки EvoBGP не получены"}</p>
|
||||
{evo.settingsLoaded&&evo.settingsLoading?<p role="status" className="text-xs text-muted-foreground">Обновление настроек EvoBGP…</p>:null}
|
||||
{evo.settingsError?<Alert variant="warning"><AlertCircleIcon aria-hidden="true"/><AlertTitle>{evo.settingsLoaded?"Настройки EvoBGP не обновлены":"Настройки EvoBGP неизвестны"}</AlertTitle><AlertDescription>{evo.settingsError}</AlertDescription></Alert>:null}
|
||||
<p role="status" className="text-xs text-muted-foreground">{evo.snapshot?"Каталог EvoBGP получен: "+date(evo.catalogReceivedAt):evo.loading?"Загрузка каталога EvoBGP…":evo.settingsLoaded&&!evo.enabled?"Каталог EvoBGP отключён в сохранённых настройках":"Каталог EvoBGP не получен"}</p>
|
||||
{evo.snapshot&&evo.loading?<p role="status" className="text-xs text-muted-foreground">Обновление каталога EvoBGP…</p>:null}
|
||||
{evo.catalogError?<Alert variant="warning"><AlertCircleIcon aria-hidden="true"/><AlertTitle>{evo.snapshot?"Каталог EvoBGP не обновлён; показаны последние данные":"Каталог EvoBGP неизвестен"}</AlertTitle><AlertDescription>{evo.catalogError}</AlertDescription></Alert>:null}
|
||||
<Button size="sm" variant="outline" disabled={mode!=="live"||backendStatus!==true||!can("mm:settings:admin")||evo.settingsLoading||evo.operationBusy} onClick={()=>{void evo.loadSettings()}}>Обновить настройки EvoBGP</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -274,3 +274,5 @@ REST запрос → ожидание → подтверждённый output /
|
||||
Завершён 11Б.3.5.1 — contracts/Evolution strict settings/save/test API: 3 unit, contracts build/typecheck/lint прошли. Новый PUT helper пока не подключён (backend старый receipt). Далее 11Б.3.5.2 backend selective save/подтверждение, затем provider и форма.
|
||||
|
||||
Завершён 11Б.3.5.2: Evolution backend selective RETURNING save/public receipt и проверка items upstream/test; DI HTTP/JWT, backend build/lint прошли. Старый provider совместим с публичными полями, ещё не мигрирован. Далее 11Б.3.5.3 — provider known/error/time/source; затем форма и upstream catalog validation.
|
||||
|
||||
Завершён 11Б.3.5.3.1: Evolution provider known/error/time/readers/source/strict receipts, статус Settings; 8 provider (один selector повторён), 2 прежних и 6 source E2E, lint/typecheck/production build прошли. Форма/consumer mocks ещё не приняты. Далее 11Б.3.5.3.2 — upstream catalog strict/unknown counts; затем 11Б.3.5.4 форма/consumers.
|
||||
+10
-1
@@ -28,7 +28,7 @@
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.2; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.3.1; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11Б.3.5.3.1 — Evolution provider и свежесть (2026-10-03)
|
||||
|
||||
- Provider подключил strict settings/save/test helpers. GET error/malformed не становится settingsLoaded; settings и catalog имеют независимые loading/error/browser receivedAt. Catalog failure сохраняет последнее snapshot/time; отключение/смена сохранённого URL/secret flag и запись ключа инвалидируют прежний каталог. Save заканчивается по receipt до независимого запроса каталога.
|
||||
- Контекст привязан к mode/backend и отдельному immutable token каждой смены; children приложения не перемонтируются. Reader revision/abort, active guards и единый save/test lock исключают публикацию старого источника. Finish сверяет конкретную session, чтобы старый callback не снимал новый lock. Provider API abort не доказывает отмену backend.
|
||||
- В Settings добавлен статус с unknown/false, свежестью и раздельными ошибками; исходные поля/dirty/effects ещё требуют миграции. Локальный ref session заменил первоначальную mutable useMemo session после замечаний ESLint; итоговый lint и typecheck прошли.
|
||||
- 8 provider E2E: 7 прошли сразу, malformed PUT selector уточнён для inline+toast старой формы и повтор прошёл. 2 предыдущих catalog/test E2E и 6 source E2E Settings/Catalog/Save (success/error) прошли; source меняется внутри текущего layout через General/backend input. Production frontend/contracts build прошёл.
|
||||
- Это приёмка provider данных, не всей формы/настроек. Старые caller toast/error/dirty guards остаются до 11Б.3.5.4; live catalog consumers ещё могут показывать mock при отключённой/unknown интеграции. Реальный Evolution/PG/Portal не проверен.
|
||||
- Далее 11Б.3.5.3.2: strict upstream catalog mapper и nullable unknown ASN/community prefix counts (сейчас backend превращает malformed lists/неизвестные числа в пустые массивы/0). Затем форма/consumers 11Б.3.5.4.
|
||||
|
||||
## Блок 11Б.3.5.2 — backend Evolution save/test (2026-10-03)
|
||||
|
||||
- Settings GET/PUT/test вынесены в DI plugin. PUT пишет только переданные нормализованные поля, RETURNING подтверждает их фактические значения, включая apiKey внутри backend; ответ отдаёт только literal ok/public flags/savedFields, без key echo. Singleton insert устойчив к конфликту.
|
||||
|
||||
@@ -202,3 +202,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
|
||||
Завершён 11Б.3.5.1 — contracts/Evolution strict settings/save/test API: 3 unit, contracts build/typecheck/lint прошли. Новый PUT helper пока не подключён (backend старый receipt). Далее 11Б.3.5.2 backend selective save/подтверждение, затем provider и форма.
|
||||
|
||||
Завершён 11Б.3.5.2: Evolution backend selective RETURNING save/public receipt и проверка items upstream/test; DI HTTP/JWT, backend build/lint прошли. Старый provider совместим с публичными полями, ещё не мигрирован. Далее 11Б.3.5.3 — provider known/error/time/source; затем форма и upstream catalog validation.
|
||||
|
||||
Завершён 11Б.3.5.3.1: Evolution provider known/error/time/readers/source/strict receipts, статус Settings; 8 provider (один selector повторён), 2 прежних и 6 source E2E, lint/typecheck/production build прошли. Форма/consumer mocks ещё не приняты. Далее 11Б.3.5.3.2 — upstream catalog strict/unknown counts; затем 11Б.3.5.4 форма/consumers.
|
||||
+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}
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testIgnore: ["**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
|
||||
testIgnore: ["**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
import base from "./playwright.config"
|
||||
const server = base.webServer
|
||||
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
|
||||
export default defineConfig({ ...base, use: { ...base.use, baseURL: "http://127.0.0.1:3101" }, testIgnore: [], testMatch: "evobgp-context.spec.ts", webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local" } } })
|
||||
@@ -0,0 +1,16 @@
|
||||
import {test,expect,type Page} from "@playwright/test"
|
||||
import {evoSettings as settings,evoCatalog as catalog} from "./fixtures/evobgp"
|
||||
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,PUT,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
|
||||
async function setup(page:Page){await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const url=new URL(r.request().url()),fresh=url.port==="8101";return r.fulfill(url.pathname==="/api/auth/config"?{headers,json:{required:false}}:url.pathname==="/api/evobgp/settings"?{headers,json:{...settings,baseUrl:fresh?"https://new.evo.test":settings.baseUrl}}:url.pathname==="/api/evobgp/catalog"?{headers,json:{...catalog,domains:[{...catalog.domains[0],domain:fresh?"fresh.example.test":"stale.example.test"}]}}:{headers,status:503,json:{error:"outside Evolution context"}})})}
|
||||
for(const action of ["settings","catalog","save"] as const)for(const failed of [false,true])test("Evolution provider: старый backend/"+action+"/"+failed,async({page})=>{
|
||||
await setup(page);let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
|
||||
const path=action==="catalog"?"/api/evobgp/catalog":"/api/evobgp/settings"
|
||||
await page.route("**"+path,async r=>{if(new URL(r.request().url()).port!=="8000"||r.request().method()==="OPTIONS"||(action==="save"?r.request().method()!=="PUT":action==="settings"?r.request().method()!=="GET":false))return r.fallback();started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА"}}:{headers,json:action==="settings"?settings:action==="catalog"?{...catalog,domains:[{...catalog.domains[0],domain:"stale.example.test"}]}:{...settings,...r.request().postDataJSON(),ok:true,savedFields:Object.keys(r.request().postDataJSON())}})}catch{}finally{completed=true}})
|
||||
try{
|
||||
await page.goto("/settings")
|
||||
if(action==="save"){await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue(settings.baseUrl);await page.getByPlaceholder("http://localhost:8080",{exact:true}).fill("https://old-write.evo.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click()}
|
||||
await expect.poll(()=>started).toBe(true);if(action==="save")await page.getByRole("button",{name:"Общие",exact:true}).click()
|
||||
const url=page.getByPlaceholder("http://localhost:8000",{exact:true});await url.fill("http://localhost:8101");await url.press("Enter");await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");release();await expect.poll(()=>completed).toBe(true)
|
||||
await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");await expect(page.getByText("СТАРАЯ ОШИБКА",{exact:true})).toHaveCount(0);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("fresh.example.test",{exact:true})).toBeVisible();await expect(page.getByText("stale.example.test",{exact:true})).toHaveCount(0)
|
||||
}finally{release()}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import {test,expect,type Page} from "@playwright/test"
|
||||
import {evoSettings as settings,evoCatalog as catalog} from "./fixtures/evobgp"
|
||||
async function setup(page:Page){await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(p==="/api/auth/config"?{json:{required:false}}:p==="/api/evobgp/settings"?{json:settings}:p==="/api/evobgp/catalog"?{json:catalog}:{status:503,json:{error:"outside Evolution fixture"}})})}
|
||||
async function open(page:Page){await page.goto("/settings");await page.getByRole("button",{name:"EvoBGP",exact:true}).click()}
|
||||
const base=(page:Page)=>page.getByPlaceholder("http://localhost:8080",{exact:true})
|
||||
const settingsStamp=(page:Page)=>page.getByRole("status").filter({hasText:/^Настройки EvoBGP получены:/})
|
||||
const catalogStamp=(page:Page)=>page.getByRole("status").filter({hasText:/^Каталог EvoBGP получен:/})
|
||||
async function ready(page:Page){await expect(base(page)).toHaveValue(settings.baseUrl);await expect(catalogStamp(page)).toBeVisible();await expect(page.getByText("Обновление каталога EvoBGP…",{exact:true})).toHaveCount(0)}
|
||||
test("Evolution provider: malformed settings остаются unknown",async({page})=>{await setup(page);await page.route("**/api/evobgp/settings",r=>r.fulfill({json:{}}));await open(page);await expect(page.getByText("Настройки EvoBGP неизвестны",{exact:true})).toBeVisible();await expect(page.getByText("Ключ: —",{exact:true})).toBeVisible();await expect(settingsStamp(page)).toHaveCount(0);await expect(base(page)).toHaveValue("")})
|
||||
test("Evolution provider: каталог не задерживает known настройки",async({page})=>{await setup(page);let started=false,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve);await page.route("**/api/evobgp/catalog",async r=>{started=true;await gate;try{await r.fulfill({json:catalog})}catch{}});try{await open(page);await expect.poll(()=>started).toBe(true);await expect(base(page)).toHaveValue(settings.baseUrl);await expect(settingsStamp(page)).toBeVisible();await expect(page.getByText("Загрузка каталога EvoBGP…",{exact:true})).toBeVisible();release();await expect(catalogStamp(page)).toBeVisible()}finally{release()}})
|
||||
test("Evolution provider: ошибка settings GET сохраняет настройки, каталог и время",async({page})=>{await setup(page);let failed=false;await page.route("**/api/evobgp/settings",r=>r.fulfill(failed?{status:503,json:{error:"settings unavailable"}}:{json:settings}));await open(page);await ready(page);const before=await settingsStamp(page).textContent(),catBefore=await catalogStamp(page).textContent();failed=true;await page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true}).click();await expect(page.getByText("Настройки EvoBGP не обновлены",{exact:true})).toBeVisible();await expect(settingsStamp(page)).toHaveText(before!);await expect(catalogStamp(page)).toHaveText(catBefore!);await expect(base(page)).toHaveValue(settings.baseUrl)})
|
||||
test("Evolution provider: ошибка каталога сохраняет последний снимок и время",async({page})=>{await setup(page);let failed=false;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog unavailable"}}:{json:catalog}));await open(page);await ready(page);const before=await catalogStamp(page).textContent();failed=true;await page.getByRole("button",{name:"Обновить каталог",exact:true}).click();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();await expect(catalogStamp(page)).toHaveText(before!);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("kept.example.test",{exact:true})).toBeVisible()})
|
||||
test("Evolution provider: отключение подтверждённой интеграции очищает каталог",async({page})=>{await setup(page);let enabled=true,posts=0;await page.route("**/api/evobgp/settings",r=>r.fulfill({json:{...settings,enabled}}));await page.route("**/api/evobgp/catalog",r=>{posts++;return r.fulfill({json:catalog})});await open(page);await ready(page);const before=posts;enabled=false;await page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true}).click();await expect(page.getByText("Интеграция: выключена",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP отключён в сохранённых настройках",{exact:true})).toBeVisible();await expect(catalogStamp(page)).toHaveCount(0);expect(posts).toBe(before)})
|
||||
test("Evolution provider: сохранение не ждёт каталог и не отменяется его отказом",async({page})=>{await setup(page);let failed=false,writes=0;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog after save unavailable"}}:{json:catalog}));await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;failed=true;const patch=r.request().postDataJSON();return r.fulfill({json:{...settings,...patch,ok:true,savedFields:Object.keys(patch)}})});await open(page);await ready(page);await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();expect(writes).toBe(1)})
|
||||
test("Evolution provider: malformed PUT не подтверждает сохранение",async({page})=>{await setup(page);let writes=0;await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;return r.fulfill({json:{}})});await open(page);await ready(page);await base(page).fill("https://new.example.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.locator("#main-content").getByText("Сохранение EvoBGP не подтверждено сервером",{exact:true})).toBeVisible();await expect(base(page)).toHaveValue("https://new.example.test");expect(writes).toBe(1)})
|
||||
test("Evolution provider: права settings admin проверяются до GET",async({page})=>{const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:dashboard:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture";await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);await setup(page);await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let reads=0;page.on("request",r=>{if(new URL(r.url()).pathname.startsWith("/api/evobgp/"))reads++});await open(page);await expect(page.getByText("Нет доступа к настройкам EvoBGP",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true})).toBeDisabled();expect(reads).toBe(0)})
|
||||
@@ -0,0 +1,2 @@
|
||||
export const evoSettings={baseUrl:"https://evo.example.test",enabled:true,secretConfigured:true}
|
||||
export const evoCatalog={fetchedAt:"2026-10-03T00:00:00Z",modules:[],domains:[{id:"fixture-domain",domain:"kept.example.test",resolvedIp:"192.0.2.1",asn:"AS64512",purpose:"fixture",filter:"fixture",updated:"сейчас",enabled:true}],ipRanges:[],asns:[],communities:[]}
|
||||
Reference in new issue
Block a user