46 lines
4.1 KiB
TypeScript
46 lines
4.1 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useState } from "react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import type { McpApi } from "@/shared/api/mcp"
|
||
|
||
export function DatabaseRestore({ api }: { api: McpApi }) {
|
||
const [connections, setConnections] = useState<{ id: string; name: string }[]>([])
|
||
const [connectionId, setConnectionId] = useState("")
|
||
const [file, setFile] = useState<File | null>(null)
|
||
const [archive, setArchive] = useState<Awaited<ReturnType<McpApi["uploadRestoreArchive"]>> | null>(null)
|
||
const [operationId, setOperationId] = useState("")
|
||
const [result, setResult] = useState<unknown>(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState("")
|
||
useEffect(() => {
|
||
const abort = new AbortController()
|
||
void api.connections(abort.signal).then(data => setConnections(data.connections.filter(item => item.policy.scope.allowGlobal && item.policy.scope.operationIds.includes("database.restore"))), failure => { if (!abort.signal.aborted) setError(String(failure)) })
|
||
return () => abort.abort()
|
||
}, [api])
|
||
const run = async (work: () => Promise<unknown>) => {
|
||
setBusy(true); setError("")
|
||
try { setResult(await work()) } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)) } finally { setBusy(false) }
|
||
}
|
||
return <section className="flex flex-col gap-3 rounded-xl border p-4" aria-labelledby="mcp-database-restore-title">
|
||
<h2 id="mcp-database-restore-title" className="font-semibold">Восстановление БД через MCP</h2>
|
||
<p className="text-sm text-muted-foreground">Архив PostgreSQL загружает администратор. Модель получает ссылку и SHA-256. Выполнение требует отдельного подтверждения в планах. После восстановления прежний MCP-доступ отзывается, а результат хранится во внешней квитанции.</p>
|
||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||
<label className="flex flex-col gap-1 text-sm">Подключение для восстановления
|
||
<select className="h-9 rounded-md border bg-background px-3" value={connectionId} onChange={event => setConnectionId(event.target.value)}><option value="">Выберите свой глобальный допуск</option>{connections.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}</select>
|
||
</label>
|
||
<label className="flex flex-col gap-1 text-sm">Архив PostgreSQL, до 64 МиБ
|
||
<Input type="file" accept=".dump,.backup" onChange={event => { setFile(event.target.files?.[0] ?? null); setArchive(null); setResult(null) }} />
|
||
</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" disabled={busy || !file || file.size > 64 * 1024 * 1024} onClick={() => { if (file) void run(async () => { const data = await api.uploadRestoreArchive(file); setArchive(data); return data }) }}>Загрузить архив</Button>
|
||
<Button disabled={busy || !archive || !connectionId} onClick={() => { if (archive) void run(() => api.restorePlan(connectionId, archive.archiveRef, archive.sha256)) }}>Подготовить план восстановления БД</Button>
|
||
</div>
|
||
{archive && <p className="break-all text-xs text-muted-foreground">SHA-256: {archive.sha256} · Допуск до {new Date(archive.expiresAt).toLocaleString()}</p>}
|
||
<label className="flex flex-col gap-1 text-sm">ID операции восстановления БД<Input value={operationId} onChange={event => setOperationId(event.target.value)} /></label>
|
||
<Button className="self-start" variant="outline" disabled={busy || !operationId} onClick={() => { void run(() => api.restoreReceipt(operationId)) }}>Прочитать внешнюю квитанцию</Button>
|
||
{result !== null && <pre role="status" className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{JSON.stringify(result, null, 2)}</pre>}
|
||
</section>
|
||
}
|