feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
import Link from "next/link"
|
||||
import {useDataSource} from "@/lib/data-source"
|
||||
import {useEvoBGP} from "@/lib/evobgp-context"
|
||||
import {can} from "@/lib/auth"
|
||||
import {Alert,AlertTitle,AlertDescription} from "@/components/reui/alert"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {EmptyState} from "@/components/empty-state"
|
||||
import {AlertCircleIcon} from "lucide-react"
|
||||
|
||||
export function useEvoCatalogView(){
|
||||
const {mode,backendStatus}=useDataSource(),evo=useEvoBGP()
|
||||
const allowed=can("mm:settings:admin")
|
||||
const snapshot=mode==="live"&&allowed&&evo.enabled?evo.snapshot:null
|
||||
return {evo,mode,backendStatus,allowed,snapshot,known:mode==="mock"||!!snapshot,loading:mode==="live"&&allowed&&!snapshot&&(evo.settingsLoading||evo.loading)}
|
||||
}
|
||||
export function EvoBgpCatalogStatus(){
|
||||
const {evo,mode,backendStatus,allowed,snapshot}=useEvoCatalogView()
|
||||
if(mode==="mock")return <p role="status" className="text-xs text-muted-foreground">Демонстрационный каталог</p>
|
||||
const reason=!allowed?"Нет доступа к каталогу EvoBGP":!evo.settingsLoaded?evo.settingsLoading?"Загрузка настроек EvoBGP…":"Настройки EvoBGP неизвестны":!evo.enabled?"Интеграция EvoBGP выключена":!snapshot?evo.loading?"Загрузка каталога EvoBGP…":"Каталог EvoBGP не получен":null
|
||||
const error=evo.settingsError||evo.catalogError
|
||||
return <div className="flex min-w-0 flex-col gap-3">
|
||||
{reason?<p role="status" className="text-sm">{reason}</p>:null}
|
||||
{snapshot?<p role="status" className="text-xs text-muted-foreground">Каталог EvoBGP получен: {evo.catalogReceivedAt?new Date(evo.catalogReceivedAt).toLocaleString("ru-RU"):"—"}{evo.loading?" · обновление…":""}</p>:null}
|
||||
{error?<Alert variant="warning"><AlertCircleIcon aria-hidden="true"/><AlertTitle>{snapshot?"Каталог не обновлён; показаны последние данные":"Каталог EvoBGP недоступен"}</AlertTitle><AlertDescription>{[evo.settingsError,evo.catalogError].filter(Boolean).join("; ")}</AlertDescription></Alert>:null}
|
||||
{allowed?<div className="flex flex-wrap gap-2"><Button size="sm" variant="outline" disabled={backendStatus!==true||evo.settingsLoading||evo.loading||evo.operationBusy} onClick={()=>{if(!evo.settingsLoaded||!evo.enabled||evo.settingsError)void evo.loadSettings();else void evo.refresh()}}>Обновить каталог EvoBGP</Button><Link className="text-sm underline underline-offset-4" href="/settings">Настройки → EvoBGP</Link></div>:null}
|
||||
</div>
|
||||
}
|
||||
export function EvoBgpCatalogUnavailable(){return <EmptyState title="Данные каталога не получены" description="Состояние загрузки указано выше. Число записей пока неизвестно." className="py-10"/>}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"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>
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect,useRef,useState} from "react"
|
||||
import Link from "next/link"
|
||||
import {useDataSource} from "@/lib/data-source"
|
||||
import {can} from "@/lib/auth"
|
||||
import {ApiClientError} from "@/shared/api/http-client"
|
||||
import {downloadSystemDatabaseBackup,restoreSystemDatabaseBackup} from "@/shared/api/system-database"
|
||||
import {OpsPanel} from "@/components/ops-panel"
|
||||
import {Button,buttonVariants} from "@/components/ui/button"
|
||||
import {FileImportDialog} from "@/components/file-import-dialog"
|
||||
import {AlertDialog,AlertDialogContent,AlertDialogHeader,AlertDialogTitle,AlertDialogDescription,AlertDialogFooter,AlertDialogCancel,AlertDialogAction} from "@/components/ui/alert-dialog"
|
||||
import {DownloadIcon,UploadIcon,LoaderCircleIcon} from "lucide-react"
|
||||
|
||||
const pending=new Set<string>()
|
||||
function storageKey(source:string){return "mm:database-restore-pending:"+source}
|
||||
function unresolved(source:string){try{return pending.has(source)||sessionStorage.getItem(storageKey(source))==="true"}catch{return pending.has(source)}}
|
||||
function checkpoint(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(storageKey(source),"true");else sessionStorage.removeItem(storageKey(source))}catch{/* Memory guard still survives section navigation. */}}
|
||||
export function SystemDatabaseSection(){const {mode,backendUrl}=useDataSource();const source=JSON.stringify([mode,backendUrl]);return <DatabaseForm key={source} source={source}/>}
|
||||
function DatabaseForm({source}:{source:string}){
|
||||
const {mode,backendUrl,backendStatus}=useDataSource()
|
||||
const [busy,setBusy]=useState<"backup"|"restore"|null>(null),[file,setFile]=useState<File|null>(null),[importOpen,setImportOpen]=useState(false)
|
||||
const [unknown,setUnknown]=useState(false),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null),[warnings,setWarnings]=useState<string[]>([])
|
||||
const [ready,setReady]=useState(false)
|
||||
const active=useRef(true),lock=useRef(false),controller=useRef<AbortController|null>(null)
|
||||
useEffect(()=>{active.current=true;queueMicrotask(()=>{if(active.current){setUnknown(unresolved(source));setReady(true)}});return()=>{active.current=false;controller.current?.abort()}},[source])
|
||||
const allowed=can("mm:settings:admin"),available=ready&&mode==="live"&&backendStatus===true&&allowed
|
||||
function current(){return active.current&&can("mm:settings:admin")}
|
||||
function begin(kind:"backup"|"restore"){
|
||||
if(!available||lock.current||!current()||(kind==="restore"&&unknown))return null
|
||||
lock.current=true;controller.current=new AbortController();setBusy(kind);setError(null);setOutcome(null);setWarnings([]);return controller.current.signal
|
||||
}
|
||||
function finish(){lock.current=false;controller.current=null;if(current())setBusy(null)}
|
||||
async function backup(){
|
||||
const signal=begin("backup");if(!signal)return
|
||||
try{
|
||||
const result=await downloadSystemDatabaseBackup(backendUrl,{signal});if(!current())return
|
||||
const url=URL.createObjectURL(result.blob)
|
||||
try{const a=document.createElement("a");a.href=url;a.download=result.filename;a.click()}finally{URL.revokeObjectURL(url)}
|
||||
setOutcome("Бэкап базы приложения подготовлен к скачиванию");setWarnings(Object.values(result.warnings))
|
||||
}catch(e){if(current())setError(e instanceof Error?e.message:"Не удалось подготовить бэкап")}
|
||||
finally{finish()}
|
||||
}
|
||||
async function restore(){
|
||||
if(!file)return
|
||||
const signal=begin("restore");if(!signal)return
|
||||
let sent=false
|
||||
try{
|
||||
const receipt=await restoreSystemDatabaseBackup(backendUrl,file,{signal,onDispatch:()=>{if(!current())throw Error("Источник изменился");sent=true;checkpoint(source,true);setUnknown(true)}})
|
||||
if(!current())return;checkpoint(source,false)
|
||||
setUnknown(false);setFile(null);setOutcome("База приложения восстановлена");setWarnings([receipt.eventError,receipt.listenerError,receipt.schedulerError,receipt.cleanupError,receipt.maintenanceError,receipt.migrationError].filter((value):value is string=>!!value))
|
||||
}catch(e){
|
||||
const rejected=e instanceof ApiClientError&&[401,403,409,413].includes(e.status)
|
||||
if(sent&&rejected&¤t())checkpoint(source,false)
|
||||
if(current()){setUnknown(sent&&!rejected);const message=e instanceof Error?e.message:"Результат восстановления не получен";setError(message)}
|
||||
}finally{finish()}
|
||||
}
|
||||
const messages=<>
|
||||
{error?<p role="alert" className="text-sm text-destructive break-words">{error}</p>:null}
|
||||
{outcome?<p role="status" className="text-sm break-words">{outcome}</p>:null}
|
||||
{warnings.map((warning,index)=><p role="status" key={index} className="text-sm text-amber-700 dark:text-amber-400 break-words">{warning}</p>)}
|
||||
{unknown?<div className="flex flex-col gap-2 text-sm"><p>Результат восстановления неизвестен. Запрос мог выполниться на сервере. До повтора проверьте данные и журнал операций; проверка доступности сервера этого не подтверждает.</p><Button variant="outline" size="sm" className="h-auto whitespace-normal" disabled={!!busy||!available} onClick={()=>{checkpoint(source,false);setUnknown(false)}}>Я проверил состояние — разрешить новый запрос</Button></div>:null}
|
||||
</>
|
||||
return <section aria-label="Операции базы приложения" className="min-w-0">
|
||||
<OpsPanel title="База данных приложения" description="Резервная копия и восстановление PostgreSQL. Для сброса потоков откройте настройки NetFlow." contentClassName="flex min-w-0 flex-col gap-4 px-5 py-4">
|
||||
{!available?<p role="status" className="text-sm text-muted-foreground">{!allowed?"Нужны права администратора настроек.":mode!=="live"?"Операции базы доступны в live-режиме.":"Доступность сервера не подтверждена."}</p>:null}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-sm font-medium">Скачать бэкап</p><p className="text-xs text-muted-foreground">Дамп PostgreSQL в формате custom (.dump)</p></div><Button variant="outline" size="sm" className="self-start" disabled={!available||!!busy} onClick={()=>void backup()}>{busy==="backup"?<LoaderCircleIcon className="size-4 animate-spin"/>:<DownloadIcon className="size-4"/>}{busy==="backup"?"Подготовка…":"Скачать"}</Button></div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-sm font-medium">Восстановить из файла</p><p className="text-xs text-muted-foreground">Полностью заменяет текущие данные. Максимум 512 МБ.</p></div><Button variant="outline" size="sm" className="self-start" disabled={!available||!!busy||unknown} onClick={()=>setImportOpen(true)}><UploadIcon className="size-4"/>Выбрать файл</Button></div>
|
||||
{!file?messages:null}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-sm font-medium">Сбросить данные NetFlow</p><p className="text-xs text-muted-foreground">Проверка и подтверждение — в разделе сбора данных</p></div><Link href="/data-collection#netflow" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть NetFlow</Link></div>
|
||||
</OpsPanel>
|
||||
<FileImportDialog open={importOpen} onOpenChange={setImportOpen} title="Восстановление базы данных" actionLabel="Продолжить" successMessage={null} description="Выберите pg_dump custom (.dump). Сначала рекомендуем скачать актуальный бэкап." accept=".dump,.backup,application/octet-stream" onImport={files=>{if(!available||lock.current||unknown)return;setFile(files[0]??null);setError(null);setOutcome(null);setWarnings([])}}/>
|
||||
<AlertDialog open={!!file} onOpenChange={open=>{if(!open&&!lock.current)setFile(null)}}>
|
||||
<AlertDialogContent className="max-h-[90dvh] overflow-y-auto min-w-0"><AlertDialogHeader><AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle><AlertDialogDescription className="break-all">Файл: {file?.name}. Текущие данные PostgreSQL будут полностью заменены. Источник: {backendUrl||"текущий сервер"}. Рекомендуется сначала скачать актуальный бэкап.</AlertDialogDescription></AlertDialogHeader>{file?messages:null}<AlertDialogFooter><AlertDialogCancel disabled={!!busy} onClick={()=>setFile(null)}>Отмена</AlertDialogCancel><AlertDialogAction variant="destructive" disabled={!available||!!busy||unknown} onClick={e=>{e.preventDefault();void restore()}}>{busy==="restore"?<LoaderCircleIcon className="size-4 animate-spin"/>:null}{busy==="restore"?"Восстановление…":"Восстановить"}</AlertDialogAction></AlertDialogFooter></AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
}
|
||||
Reference in new issue
Block a user