Files

99 lines
10 KiB
TypeScript

"use client"
import {useEffect,useRef,useState} from "react"
import {evoBgpSettingsPatchSchema,evoBgpTestDraftSchema,type EvoBgpSavePayload} from "@mmapp/contracts/evobgp"
import {useEvoBGP} from "@/lib/evobgp-context"
import {useDataSource} from "@/lib/data-source"
import {can} from "@/lib/auth"
import {EvoBgpDataStatus} from "./evobgp-data-status"
import {OpsPanel} from "@/components/ops-panel"
import {FormField,FormToggle} from "@/components/form-kit"
import {Alert,AlertDescription,AlertTitle} from "@/components/reui/alert"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {AlertDialog,AlertDialogContent,AlertDialogHeader,AlertDialogTitle,AlertDialogDescription,AlertDialogFooter,AlertDialogCancel,AlertDialogAction} from "@/components/ui/alert-dialog"
import {AlertCircleIcon,EyeIcon,EyeOffIcon} from "lucide-react"
export function EvoBgpSettingsSection(){const evo=useEvoBGP();return <EvoBgpSettingsForm key={evo.sourceKey}/>}
function EvoBgpSettingsForm(){
const evo=useEvoBGP(),{mode,backendStatus}=useDataSource()
const [baseUrl,setBaseUrl]=useState(evo.settingsLoaded?evo.baseUrl:"")
const [enabled,setEnabled]=useState(evo.settingsLoaded?evo.enabled:false)
const [apiKey,setApiKey]=useState("")
const [showKey,setShowKey]=useState(false)
const [changed,setChanged]=useState(false)
const [busy,setBusy]=useState<"save"|"test"|"clear"|"refresh"|null>(null)
const [error,setError]=useState<string|null>(null)
const [outcome,setOutcome]=useState<string|null>(null)
const [testResult,setTestResult]=useState<{ok:boolean;message:string}|null>(null)
const [clearOpen,setClearOpen]=useState(false)
const dirty=useRef(new Set<"baseUrl"|"enabled"|"apiKey">())
const active=useRef(true),lock=useRef(false)
useEffect(()=>{active.current=true;return()=>{active.current=false}},[])
useEffect(()=>{if(!evo.settingsLoaded)return;let current=true;queueMicrotask(()=>{if(!current||!active.current)return;if(!dirty.current.has("baseUrl"))setBaseUrl(evo.baseUrl);if(!dirty.current.has("enabled"))setEnabled(evo.enabled)});return()=>{current=false}},[evo.settingsLoaded,evo.baseUrl,evo.enabled,evo.settingsReceivedAt])
const writable=mode==="live"&&backendStatus===true&&can("mm:settings:admin")&&evo.settingsLoaded&&!evo.settingsError
const blocked=!!busy||evo.operationBusy
const mark=(field:"baseUrl"|"enabled"|"apiKey")=>{dirty.current.add(field);setChanged(true);setOutcome(null);setTestResult(null)}
function begin(kind:NonNullable<typeof busy>){if(!active.current||lock.current||!writable||evo.operationBusy)return false;lock.current=true;setBusy(kind);setError(null);setOutcome(null);setTestResult(null);return true}
function finish(){lock.current=false;if(active.current)setBusy(null)}
async function save(){
if(!changed||!begin("save"))return
try{
const patch:EvoBgpSavePayload={}
if(dirty.current.has("baseUrl"))patch.baseUrl=baseUrl
if(dirty.current.has("enabled"))patch.enabled=enabled
if(dirty.current.has("apiKey")&&apiKey.trim())patch.apiKey=apiKey
const parsed=evoBgpSettingsPatchSchema.safeParse(patch)
if(!parsed.success)throw new Error(parsed.error.issues[0]?.message??"Проверьте поля EvoBGP")
if(parsed.data.apiKey==="")throw new Error("Введите API-ключ; удаление сохранённого ключа подтверждается отдельно")
await evo.saveSettings(parsed.data)
if(!active.current)return
for(const field of Object.keys(parsed.data))dirty.current.delete(field as "baseUrl"|"enabled"|"apiKey")
if(parsed.data.baseUrl!==undefined)setBaseUrl(parsed.data.baseUrl)
if(parsed.data.apiKey!==undefined){setApiKey("");setShowKey(false)}
setChanged(dirty.current.size>0);setOutcome("Настройки EvoBGP сохранены")
}catch(error){if(active.current)setError((error instanceof Error?error.message:"Не удалось сохранить EvoBGP")+". Черновик сохранён; автоматического повтора нет.")}
finally{finish()}
}
async function test(){
if(!begin("test"))return
try{
const draft=evoBgpTestDraftSchema.safeParse({baseUrl,...(apiKey.trim()?{apiKey}:{})})
if(!draft.success)throw new Error(draft.error.issues[0]?.message??"Проверьте поля EvoBGP")
if(!draft.data.baseUrl||(!draft.data.apiKey&&!evo.secretConfigured))throw new Error("Укажите URL и API-ключ для проверки")
const result=await evo.testConnection(draft.data)
if(active.current)setTestResult(result)
}catch(error){if(active.current)setError(error instanceof Error?error.message:"Не удалось проверить EvoBGP")}
finally{finish()}
}
async function clear(){
if(!evo.secretConfigured||!begin("clear"))return
try{await evo.saveSettings({apiKey:null});if(active.current){setClearOpen(false);setOutcome("Удаление сохранённого ключа подтверждено")}}
catch(error){if(active.current)setError((error instanceof Error?error.message:"Удаление ключа не подтверждено")+". Подтверждения удаления нет; автоматического повтора нет.")}
finally{finish()}
}
async function refresh(){if(!begin("refresh"))return;try{await evo.refresh()}finally{finish()}}
return <section aria-label="Настройки EvoBGP" className="flex min-w-0 flex-col gap-4">
<EvoBgpDataStatus/>
{mode!=="live"?<p role="status" className="text-sm text-muted-foreground">Интеграция доступна в live-режиме. Выберите живые данные в разделе «Общие».</p>:null}
<OpsPanel title="EvoBGP API" description="Каталог доменов, IP-диапазонов, ASN и communities. Проверка использует поля формы; обновление каталога — сохранённые настройки." contentClassName="flex min-w-0 flex-col gap-4 px-5 py-4">
<FormField label="Базовый URL API" hint="HTTP(S) адрес без пути /v1, параметров и учётных данных"><Input aria-label="Базовый URL API" placeholder="http://localhost:8080" value={baseUrl} onChange={e=>{mark("baseUrl");setBaseUrl(e.target.value)}} disabled={!writable||blocked} className="min-w-0 font-mono" autoComplete="url"/></FormField>
<FormField label="API-ключ" hint={evo.secretConfigured?"Оставьте пустым, чтобы сохранить текущий ключ. Введите новый только для замены.":"Введите ключ для подключения к каталогу."}>
<div className="flex min-w-0 items-center gap-2"><Input aria-label="API-ключ EvoBGP" type={showKey?"text":"password"} autoComplete="off" value={apiKey} onChange={e=>{setApiKey(e.target.value);if(e.target.value.trim())mark("apiKey");else{dirty.current.delete("apiKey");setChanged(dirty.current.size>0)}}} placeholder={evo.secretConfigured?"Оставьте пустым, чтобы не менять":"Bearer-токен"} disabled={!writable||blocked} className="min-w-0 font-mono"/><Button type="button" variant="outline" size="icon" aria-label={showKey?"Скрыть API-ключ":"Показать API-ключ"} disabled={!writable||blocked} onClick={()=>setShowKey(value=>!value)}>{showKey?<EyeOffIcon/>:<EyeIcon/>}</Button></div>
</FormField>
<div className="flex items-center gap-3"><FormToggle ariaLabel="Использовать каталог EvoBGP" checked={enabled} disabled={!writable||blocked} onChange={value=>{mark("enabled");setEnabled(value)}}/><span className="text-sm">Использовать каталог EvoBGP{!evo.settingsLoaded?": —":""}</span></div>
{error?<Alert variant="destructive"><AlertCircleIcon aria-hidden="true"/><AlertTitle>Операция EvoBGP требует внимания</AlertTitle><AlertDescription>{error}</AlertDescription></Alert>:null}
{outcome?<p role="status" className="text-sm">{outcome}</p>:null}
{testResult?<p role="status" className={testResult.ok?"text-sm text-success":"text-sm text-destructive"}>{testResult.message}</p>:null}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" disabled={!writable||blocked||!changed} onClick={()=>{void save()}}>{busy==="save"?"Сохранение…":"Сохранить"}</Button>
<Button type="button" size="sm" variant="outline" disabled={!writable||blocked||!baseUrl.trim()||(!apiKey.trim()&&!evo.secretConfigured)} onClick={()=>{void test()}}>{busy==="test"?"Проверка…":"Проверить ключ"}</Button>
<Button type="button" size="sm" variant="outline" disabled={!writable||blocked||evo.loading||!evo.enabled} onClick={()=>{void refresh()}}>Обновить каталог</Button>
<Button type="button" size="sm" variant="outline" disabled={!writable||blocked||!evo.secretConfigured} onClick={()=>setClearOpen(true)}>Удалить сохранённый ключ</Button>
</div>
<p className="text-xs text-muted-foreground">Проверка соединения не сохраняет изменения. Пустое поле ключа не удаляет сохранённый ключ.</p>
</OpsPanel>
<AlertDialog open={clearOpen} onOpenChange={value=>{if(!blocked)setClearOpen(value)}}><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Удалить сохранённый ключ EvoBGP?</AlertDialogTitle><AlertDialogDescription>Каталог не сможет обновляться до сохранения нового ключа. Остальные поля и введённый новый ключ останутся в форме.</AlertDialogDescription></AlertDialogHeader>{error?<p role="alert" className="text-sm text-destructive">{error}</p>:null}<AlertDialogFooter><AlertDialogCancel disabled={blocked}>Отмена</AlertDialogCancel><AlertDialogAction variant="destructive" disabled={!writable||blocked} onClick={event=>{event.preventDefault();void clear()}}>{busy==="clear"?"Удаление…":"Удалить ключ"}</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog>
</section>
}