Files
MikrotikManager/app/(main)/backups/page.tsx
T

598 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { Frame, FramePanel } from "@/components/reui/frame"
import { PageHeader } from "@/components/page-header"
import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data"
import { useBackupsUrlState } from "@/hooks/use-backups-url-state"
import { backupStorageSettingsDtoSchema, type BackupScheduleSettingsDto, type BackupStorageSettingsDto } from "@mmapp/contracts/backups"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
CloudIcon,
ClockIcon,
HardDriveIcon,
PlusIcon,
RefreshCwIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { serverListSchema } from "@mmapp/contracts/servers"
import { can } from "@/lib/auth"
import { userApiError } from "@/shared/api/users"
import { toFrontendServer } from "@/entities/server/model/mappers"
import {
deleteBackup,
downloadBackup,
getBackupScheduleSettings,
getBackupStorageSettings,
listBackups,
putBackupScheduleSettings,
putBackupStorageSettings,
restoreBackup,
syncBackupsFromStorage,
testBackupStorage,
type BackupItem,
} from "@/shared/api/backups"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
import { toast } from "sonner"
import { useBackupRestoreGuard } from "@/lib/use-backup-restore-guard"
import { restoreWasNotApplied } from "@/lib/backup-restore-checkpoint"
import { Checkbox } from "@/components/ui/checkbox"
import { useBackupJob } from "@/lib/use-backup-job"
import { backupJobOutcome, backupJobResultError } from "@/lib/backup-job-poll"
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
import { BackupsHistory } from "@/components/backups/backups-history"
import {
BackupsSettings,
WEEK_DAYS,
defaultSchedule,
defaultStorageForm,
type BackupScheduleForm,
type BackupStorageForm,
} from "@/components/backups/backups-settings"
import { BackupCreateSheet } from "@/components/backups/backup-create-sheet"
import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs"
type PageTab = "history" | "settings"
type Resource = "backups" | "servers" | "schedule" | "storage"
type ResourceState = { known: boolean; error: string | null; receivedAt: string | null }
const unknownResource = (): ResourceState => ({ known: false, error: null, receivedAt: null })
export default function BackupsPage() { return <Suspense fallback={<p role="status" className="p-5">Загрузка контекста резервных копий…</p>}><BackupsSource /></Suspense> }
function BackupsSource() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
return <BackupsContent key={mode + ":" + backendUrl} />
}
function BackupsContent() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const canRead = can("mm:backups:read")
const canWrite = isLive && can("mm:backups:write")
const canReadServers = can("mm:servers:read")
const [resources, setResources] = useState<Record<Resource, ResourceState>>(() => ({ backups: unknownResource(), servers: unknownResource(), schedule: unknownResource(), storage: unknownResource() }))
const reader = useRef<AbortController | null>(null)
const revision = useRef(0)
const alive = useRef(true)
const scheduleDirty = useRef(false)
const storageDirty = useRef(false)
const operationLock = useRef(false)
const operationController = useRef<AbortController | null>(null)
const downloadControllers = useRef(new Map<string, AbortController>())
const settingsLock = useRef(false)
const settingsController = useRef<AbortController | null>(null)
const known = (resource: Resource) => !isLive || resources[resource].known
const current = (resource: Resource) => known(resource) && !resources[resource].error
const { tab, setTab, kindFilter, setKindFilter, search, setSearch, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, reset: resetContext } = useBackupsUrlState()
const [backupList, setBackupList] = useState<Backup[]>(isLive ? [] : mockBackups)
const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers)
const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
const [loading, setLoading] = useState(false)
const restoreGuard = useBackupRestoreGuard(backendUrl, isLive)
const [restoreReviewed, setRestoreReviewed] = useState(false)
const [opBusy, setOpBusy] = useState(false)
const [opError, setOpError] = useState<string | null>(null)
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
const [testBusy, setTestBusy] = useState(false)
const [syncBusy, setSyncBusy] = useState(false)
const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule)
const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm)
const [storageIsDirty, setStorageIsDirty] = useState(false)
const [savedSchedule, setSavedSchedule] = useState<BackupScheduleSettingsDto | null>(null)
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
const [allEnabled, setAllEnabled] = useState(true)
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
const [manualOpen, setManualOpen] = useState(false)
const [manualStep, setManualStep] = useState(1)
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
const [manualNotes, setManualNotes] = useState("")
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null)
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) }
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => { if (k !== "showPassword") { storageDirty.current = true; setStorageIsDirty(true) } setStorage(s => ({ ...s, [k]: v })) }
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
const kb = Math.round(b.sizeBytes / 1024)
return {
id: b.id,
server: b.serverName,
serverId: b.serverId == null ? null : String(b.serverId),
filename: b.filename,
size: b.sizeBytes < 1024 ? `${b.sizeBytes} Б` : `${kb} КБ`,
sizeBytes: b.sizeBytes,
created: new Date(b.createdAt).toLocaleString("ru-RU"),
kind: b.kind,
notes: b.notes ?? "",
storage: b.storage,
uploadError: b.uploadError ?? undefined,
}
}, [])
const loadLive = useCallback(async () => {
if (!isLive || !canRead || !alive.current) return
reader.current?.abort()
const abort = new AbortController(), version = ++revision.current
reader.current = abort
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)])
const active = () => alive.current && !abort.signal.aborted && revision.current === version
const success = (resource: Resource) => { if (active()) setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } })) }
const failure = (resource: Resource, error: unknown) => { if (active()) setResources(prev => ({ ...prev, [resource]: { ...prev[resource], error: userApiError(error) } })) }
setLoading(true)
await Promise.allSettled([
listBackups(backendUrl, { signal }).then(rows => { if (!active()) return; setBackupList(rows.map(mapApiBackupToUi)); success("backups") }).catch(error => failure("backups", error)),
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
const rows = serverListSchema.parse(payload)
if (!active()) return
setLiveServers(rows.map(row => ({ ...toFrontendServer(row), status: row.status ?? "unknown" }))); success("servers")
}).catch(error => failure("servers", error)) : Promise.resolve(),
getBackupScheduleSettings(backendUrl, { signal }).then(row => {
if (!active()) return
if (!scheduleDirty.current) { setSchedule(row); setSelectedServers(new Set(row.serverIds)); setAllEnabled(row.serverIds.length === 0) }
setSavedSchedule(row); setScheduleLastError(row.lastError ?? null); success("schedule")
}).catch(error => failure("schedule", error)),
getBackupStorageSettings(backendUrl, { signal }).then(row => {
if (!active()) return
setSavedStorage(row)
if (!storageDirty.current) setStorage(prev => ({ ...prev, ...row, s3SecretAccessKey: "" }))
success("storage")
}).catch(error => failure("storage", error)),
])
if (active()) setLoading(false)
}, [backendUrl, isLive, canRead, canReadServers, mapApiBackupToUi])
useEffect(() => {
alive.current = true
const downloads = downloadControllers.current
queueMicrotask(() => { if (alive.current) void loadLive() })
return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort(); operationController.current?.abort(); downloads.forEach(controller => controller.abort()) }
}, [loadLive])
const job = useBackupJob(backendUrl, isLive, canRead, baseCanCreate && !opBusy && !scheduleSaveBusy, loadLive)
const checkpoint = job.checkpoint
const jobTerminal = !!checkpoint?.job && ["done", "failed"].includes(checkpoint.job.status)
const backupJobId = checkpoint && !jobTerminal ? checkpoint.request.requestId : null
const persistedJobError = checkpoint?.job && jobTerminal ? backupJobResultError(checkpoint.job) : null
const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError
function startSettingsMutation() {
if (!alive.current || settingsLock.current || operationLock.current || backupJobId) return null
settingsLock.current = true
const abort = new AbortController()
settingsController.current = abort
reader.current?.abort(); revision.current += 1; setLoading(false)
setScheduleSaveBusy(true); setOpError(null)
return abort
}
function finishSettingsMutation(abort: AbortController) {
if (settingsController.current !== abort) return
settingsLock.current = false; settingsController.current = null
if (alive.current) { setScheduleSaveBusy(false); setTestBusy(false); setSyncBusy(false) }
}
function settingsConfirmed(resource: Resource) {
setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
}
async function handleSave() {
if (!canWrite || !current("schedule") || !current("servers") || !canReadServers) return
const abort = startSettingsMutation(); if (!abort) return
try {
const payload = backupSchedulePayload(schedule, [...selectedServers], allEnabled)
if (payload.serverIds?.some(id => !liveServers.some(server => server.id === id))) throw new Error("Выбранный сервер отсутствует; обновите или измените выбор")
const result = await putBackupScheduleSettings(backendUrl, payload, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
setSavedSchedule(result); setSchedule(result); setScheduleLastError(result.lastError ?? null); scheduleDirty.current = false
setSelectedServers(new Set(result.serverIds)); setAllEnabled(result.serverIds.length === 0); settingsConfirmed("schedule")
toast.success("Расписание сохранено")
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { finishSettingsMutation(abort) }
}
async function handleSaveStorage() {
if (!canWrite || !current("storage")) return
const abort = startSettingsMutation(); if (!abort) return
try {
const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage, savedStorage?.secretConfigured), { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; setStorageIsDirty(false); settingsConfirmed("storage")
toast.success("Хранилище сохранено")
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { finishSettingsMutation(abort) }
}
async function handleTestStorage() {
if (!canWrite || !current("storage")) return
if (storageDirty.current) { setOpError("Сначала сохраните хранилище; проверка использует подтверждённые настройки"); return }
if (savedStorage?.provider !== "s3") { setOpError("Проверка доступна для сохранённого хранилища S3"); return }
const abort = startSettingsMutation(); if (!abort) return
setTestBusy(true)
try {
const result = await testBackupStorage(backendUrl, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
setSavedStorage(result); settingsConfirmed("storage")
if (result.lastTestError || !result.lastTestAt || result.provider !== "s3") throw new Error(result.lastTestError || "Проверка S3 не подтверждена")
toast.success("Доступ к S3 подтверждён")
} catch (error) {
if (alive.current && !abort.signal.aborted) {
if (error instanceof ApiClientError && error.payload && typeof error.payload === "object" && "settings" in error.payload) {
const parsed = backupStorageSettingsDtoSchema.safeParse(error.payload.settings)
if (parsed.success) setSavedStorage(parsed.data)
}
setOpError(userApiError(error))
}
}
finally { finishSettingsMutation(abort) }
}
async function handleSyncStorage() {
if (!canWrite || !current("storage")) return
if (storageDirty.current) { setOpError("Сначала сохраните хранилище; синхронизация использует подтверждённые настройки"); return }
if (savedStorage?.provider !== "s3") return
const abort = startSettingsMutation(); if (!abort) return
setSyncBusy(true)
try {
const result = await syncBackupsFromStorage(backendUrl, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
toast.success("Импортировано: " + result.imported + ", пропущено: " + result.skipped)
await loadLive()
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { finishSettingsMutation(abort) }
}
async function handleManualBackup() {
if (checkpoint) { await job.run(); return }
if (!canCreate || !manualServers.size || settingsLock.current || opBusy) return
if ([...manualServers].some(id => !liveServers.some(server => server.id === id))) { setOpError("Выбранный сервер отсутствует; обновите выбор"); return }
await job.run({ serverIds: [...manualServers], notes: manualNotes || undefined })
}
useEffect(() => {
if (opError) toast.error(opError)
}, [opError])
function startOperation() {
if (!canWrite || !alive.current || operationLock.current || settingsLock.current || backupJobId) return null
operationLock.current = true
const abort = new AbortController(); operationController.current = abort
reader.current?.abort(); revision.current += 1; setLoading(false)
setOpBusy(true); setOpError(null)
return abort
}
function finishOperation(abort: AbortController) {
if (operationController.current !== abort) return
operationLock.current = false; operationController.current = null
if (alive.current) setOpBusy(false)
}
async function handleDelete() {
if (!deleteTarget) return
const target = deleteTarget, abort = startOperation(); if (!abort) return
try {
await deleteBackup(backendUrl, target.id, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
setBackupList(previous => previous.filter(backup => backup.id !== target.id)); setDeleteTarget(null)
toast.success("API подтвердил удаление резервной копии")
await loadLive()
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { finishOperation(abort) }
}
async function handleRestore() {
if (!restoreTarget || !restoreTarget.serverId || !restoreGuard.canStart) return
const target = restoreTarget, abort = startOperation(); if (!abort) return
let sent = false
try {
if (!restoreGuard.begin({ backupId: target.id, serverId: Number(target.serverId), filename: target.filename, serverName: target.server })) return
sent = true
const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }, Number(target.serverId))
if (!alive.current || abort.signal.aborted) return
if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу")
toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName)
restoreGuard.clear(); setRestoreTarget(null)
} catch (error) {
if (alive.current && !abort.signal.aborted) {
if (sent && restoreWasNotApplied(error)) restoreGuard.clear()
setOpError(userApiError(error) + (sent && !restoreWasNotApplied(error) ? ". Результат импорта неизвестен; проверьте роутер перед повтором" : ""))
}
}
finally { finishOperation(abort) }
}
async function handleDownload(id: string, filename: string) {
if (!isLive || !canRead || !alive.current || downloadControllers.current.has(id)) return
const abort = new AbortController(); downloadControllers.current.set(id, abort)
try {
const blob = await downloadBackup(backendUrl, id, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
const url = URL.createObjectURL(blob)
try {
const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click()
} finally { URL.revokeObjectURL(url) }
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { if (downloadControllers.current.get(id) === abort) downloadControllers.current.delete(id) }
}
const autoCount = backupList.filter((b) => b.kind === "auto").length
const manualCount = backupList.filter((b) => b.kind === "manual").length
const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length
const schedSummary = (() => {
const confirmed = isLive ? savedSchedule : defaultSchedule
if (!confirmed) return "Расписание пока неизвестно"
if (!confirmed.enabled) return "Расписание отключено"
const time = String(confirmed.hour).padStart(2, "0") + ":" + String(confirmed.minute).padStart(2, "0")
if (confirmed.frequency === "daily") return "Каждый день в " + time
if (confirmed.frequency === "weekly") return "Каждую неделю (" + WEEK_DAYS[Number(confirmed.weekDay)] + ") в " + time
return confirmed.monthDay + "-го числа каждого месяца в " + time
})()
const confirmedStorage = isLive ? savedStorage : defaultStorageForm
const destinationLabel = !confirmedStorage ? "Хранилище пока неизвестно" : confirmedStorage.provider === "s3"
? (confirmedStorage.keepLocalCopy ? "локально и в S3" : "в S3")
: "локально (storage/backups)"
const statusDetail = [
backupJobId ? null : `${destinationLabel} · ${known("servers") ? liveServers.length + " серверов в каталоге" : "Серверы пока неизвестны"}`,
scheduleLastError,
savedStorage?.lastTestError,
].filter(Boolean).join(" · ")
const alertVariant = backupJobId
? "info"
: opError || scheduleLastError || savedStorage?.lastTestError
? "warning"
: "default"
function openCreate(all: boolean) {
if (checkpoint) { setManualStep(3); setManualOpen(true); return }
if (!canCreate) return
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
setManualNotes("")
setManualStep(1)
setManualOpen(true)
}
return (
<div className="flex min-h-0 min-w-0 flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
actions={
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openCreate(true)}
disabled={!canCreate || loading || job.busy}
>
<RefreshCwIcon className="size-4" />
Снять со всех
</Button>
<Button
type="button"
size="sm"
onClick={() => openCreate(false)}
disabled={checkpoint ? !canRead : !canCreate || loading || job.busy}
>
<PlusIcon className="size-4" />
{checkpoint ? "Задача резервного копирования" : "Новый бэкап"}
</Button>
</>
}
/>
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
<div className="flex flex-col gap-5">
{urlError && <Alert variant="warning" aria-label="Контекст резервных копий"><AlertTitle>Не удалось прочитать ссылку</AlertTitle><AlertDescription>{urlError}</AlertDescription><AlertAction><Button type="button" variant="outline" onClick={resetContext}>Сбросить контекст</Button></AlertAction></Alert>}
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демонстрационные данные. Операции и сохранение недоступны.</p>}
{!canRead && <p role="status">Нет права mm:backups:read</p>}
{isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers:read.</p>}
{isLive && canRead && <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<Button type="button" variant="outline" size="sm" onClick={() => void loadLive()} disabled={loading || scheduleSaveBusy || opBusy}>Обновить</Button>
<span role="status">{loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}</span>
</div>}
{isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource => <Alert key={resource} variant="warning" aria-label={"Данные резервных копий: " + resource}>
<AlertTitle>{resources[resource].known ? "Последние данные сохранены" : "Данные пока неизвестны"}</AlertTitle>
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
</Alert>)}
{isLive && (restoreGuard.checkpoint || restoreGuard.error) && <Alert variant="warning" aria-label="Подтверждение восстановления">
<AlertTitle>{opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"}</AlertTitle>
<AlertDescription className="flex flex-col gap-3">
<span>{restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")}</span>
<label className="flex items-start gap-2"><Checkbox checked={restoreReviewed} disabled={opBusy || !canWrite} onCheckedChange={value => setRestoreReviewed(value === true)} />Я проверил состояние роутера и понимаю последствия повторного импорта</label>
<Button type="button" variant="outline" disabled={!restoreReviewed || opBusy || !canWrite} onClick={() => { if (restoreGuard.clear()) { setRestoreReviewed(false); setOpError(null) } }}>Разрешить новое восстановление</Button>
</AlertDescription>
</Alert>}
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
{(job.error || job.storageError || persistedJobError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError || persistedJobError}</AlertDescription></Alert>}
<div><h1 className="text-xl font-semibold">Резервные копии</h1><p className="text-sm text-muted-foreground">История файлов, расписание и подтверждения операций</p></div>
<KpiStatGrid
aria-label="Сводка бэкапов"
items={[
{
id: "all",
label: "Всего бэкапов",
value: known("backups") ? backupList.length : "—",
icon: <HardDriveIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "auto",
label: "Авто",
value: known("backups") ? autoCount : "—",
icon: <ClockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "manual",
label: "Вручную",
value: known("backups") ? manualCount : "—",
icon: <PlusIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "s3",
label: "В S3",
value: known("backups") ? s3Count : "—",
icon: <CloudIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<Alert variant={alertVariant}>
<ClockIcon />
<AlertTitle>
{backupJobId ? (checkpoint?.accepted ? "Задача наблюдается" : "Результат запроса пока неизвестен") : schedSummary}
</AlertTitle>
<AlertDescription>
{backupJobId
? "Закрытие формы не отменяет задачу. Проверка продолжает чтение того же ID."
: statusDetail}
</AlertDescription>
<AlertAction>
<Button type="button" variant="ghost" size="xs" onClick={() => setTab("settings")}>
Настройки
</Button>
</AlertAction>
</Alert>
<Frame dense className="min-w-0"><FramePanel className="min-w-0 p-3 sm:p-5">
<Tabs
value={tab}
onValueChange={(v) => setTab(String(v) as PageTab)}
className="gap-5"
>
<TabsList variant="line" className="w-fit">
<TabsTrigger value="history">История бэкапов</TabsTrigger>
<TabsTrigger value="settings">Настройки</TabsTrigger>
</TabsList>
<TabsContent value="history" className="flex flex-col gap-5">
{urlError ? null : !canRead ? <p role="status">История недоступна без права чтения</p> : !known("backups") ? <p role="status">{resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}</p> : <BackupsHistory
backups={backupList}
kindFilter={kindFilter}
onKindFilterChange={setKindFilter}
search={search}
onSearchChange={setSearch}
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
onDownload={handleDownload}
onRestore={backup => { setOpError(null); setRestoreTarget(backup) }}
onDelete={backup => { setOpError(null); setDeleteTarget(backup) }}
onCreate={() => openCreate(false)}
canCreate={canCreate}
canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId}
canRestore={restoreGuard.canStart}
canDownload={isLive && canRead}
/>}
</TabsContent>
<TabsContent value="settings">
<BackupsSettings
schedule={schedule}
onScheduleChange={setSched}
storage={storage}
onStorageChange={setStore}
savedStorage={savedStorage}
servers={liveServers}
selectedServers={selectedServers}
onToggleServer={(id) => {
scheduleDirty.current = true
setSelectedServers((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}}
onSelectAll={() => { scheduleDirty.current = true; setAllEnabled(false); setSelectedServers(new Set(liveServers.map(s => s.id))) }}
onClearServers={() => { scheduleDirty.current = true; setSelectedServers(new Set()) }}
onSave={handleSave}
onSaveStorage={handleSaveStorage}
scheduleKnown={known("schedule")}
storageKnown={known("storage")}
storageDirty={storageIsDirty}
scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers") && !backupJobId}
storageWritable={canWrite && current("storage") && !backupJobId}
allEnabled={allEnabled}
onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }}
onTest={handleTestStorage}
onSync={handleSyncStorage}
saveBusy={scheduleSaveBusy}
testBusy={testBusy}
syncBusy={syncBusy}
/>
</TabsContent>
</Tabs>
</FramePanel></Frame>
</div>
</div>
<BackupCreateSheet
open={manualOpen}
onOpenChange={setManualOpen}
step={manualStep}
onStepChange={setManualStep}
servers={liveServers}
selected={checkpoint ? new Set(checkpoint.request.serverIds.map(String)) : manualServers}
onToggle={(id) => {
setManualServers((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}}
onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
onClear={() => setManualServers(new Set())}
notes={checkpoint?.request.notes ?? manualNotes}
onNotesChange={setManualNotes}
destinationLabel={destinationLabel}
busy={job.busy}
locked={!!checkpoint}
terminal={jobTerminal}
canObserve={canRead}
jobId={checkpoint?.request.requestId}
jobStatus={checkpoint?.job ? ({ queued: "В очереди", running: "Выполняется", done: checkpoint?.job && backupJobOutcome(checkpoint.job).fullSuccess ? "Завершена" : "Завершена с ограничениями", failed: "Прервана" })[checkpoint.job.status] + ": " + checkpoint.job.completed + "/" + checkpoint.job.total : checkpoint?.accepted ? "Запрос принят, результат ещё неизвестен" : "Результат запроса ещё неизвестен"}
error={job.error || job.storageError || persistedJobError}
result={checkpoint?.job && jobTerminal ? "Создано " + backupJobOutcome(checkpoint.job).created + " из " + checkpoint.job.total : undefined}
onReset={() => { if (job.reset()) { setManualServers(new Set()); setManualNotes(""); setManualStep(1) } }}
onSubmit={handleManualBackup}
/>
<BackupDeleteDialog
backup={deleteTarget}
busy={opBusy}
error={opError}
onConfirm={() => void handleDelete()}
onCancel={() => setDeleteTarget(null)}
/>
<BackupRestoreDialog
backup={restoreTarget}
busy={opBusy}
blocked={!restoreGuard.canStart}
error={opError}
onConfirm={() => void handleRestore()}
onCancel={() => setRestoreTarget(null)}
/>
</div>
)
}