"use client" import { useCallback, useEffect, useState } from "react" import { PageHeader } from "@/components/page-header" import type { Backup, Server } from "@/lib/data" import type { Filter } from "@/components/reui/filters" import 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 { listServers } from "@/shared/api/servers" import { toFrontendServer } from "@/entities/server/model/mappers" import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, getBackupStorageSettings, listBackups, putBackupScheduleSettings, putBackupStorageSettings, restoreBackup, syncBackupsFromStorage, testBackupStorage, type BackupItem, } from "@/shared/api/backups" import { requestBlob } from "@/shared/api/http-client" import { toast } from "sonner" 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 KindFilter = "all" | "auto" | "manual" export default function BackupsPage() { const { backendUrl } = useDataSource() const [tab, setTab] = useState("history") const [backupList, setBackupList] = useState([]) const [kindFilter, setKindFilter] = useState("all") const [search, setSearch] = useState("") const [advancedFilters, setAdvancedFilters] = useState([]) const [liveServers, setLiveServers] = useState([]) const [loading, setLoading] = useState(false) const [opBusy, setOpBusy] = useState(false) const [opError, setOpError] = useState(null) const [backupJobId, setBackupJobId] = useState(null) const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false) const [testBusy, setTestBusy] = useState(false) const [syncBusy, setSyncBusy] = useState(false) const [schedule, setSchedule] = useState(defaultSchedule) const [storage, setStorage] = useState(defaultStorageForm) const [savedStorage, setSavedStorage] = useState(null) const [scheduleLastError, setScheduleLastError] = useState(null) const [selectedServers, setSelectedServers] = useState>(new Set()) const [manualOpen, setManualOpen] = useState(false) const [manualStep, setManualStep] = useState(1) const [manualServers, setManualServers] = useState>(new Set()) const [manualNotes, setManualNotes] = useState("") const [deleteTarget, setDeleteTarget] = useState(null) const [restoreTarget, setRestoreTarget] = useState(null) const setSched = (k: K, v: BackupScheduleForm[K]) => setSchedule((s) => ({ ...s, [k]: v })) const setStore = (k: K, v: BackupStorageForm[K]) => setStorage((s) => ({ ...s, [k]: v })) const mapApiBackupToUi = useCallback((b: BackupItem): Backup => { const kb = Math.max(1, Math.round(b.sizeBytes / 1024)) return { id: b.id, server: b.serverName, serverId: b.serverId == null ? null : String(b.serverId), filename: b.filename, size: `${kb} КБ`, created: new Date(b.createdAt).toLocaleString("ru-RU"), kind: b.kind, notes: b.notes ?? "", storage: b.storage ?? "local", uploadError: b.uploadError ?? undefined, } }, []) const loadLive = useCallback(async () => { setLoading(true) setOpError(null) try { const [serversRows, backupsRows, scheduleRow, storageRow] = await Promise.all([ listServers(backendUrl), listBackups(backendUrl), getBackupScheduleSettings(backendUrl), getBackupStorageSettings(backendUrl), ]) const mappedServers = serversRows.map((s) => toFrontendServer(s)) setLiveServers(mappedServers) setBackupList(backupsRows.map(mapApiBackupToUi)) setSchedule({ enabled: scheduleRow.enabled, frequency: scheduleRow.frequency, hour: scheduleRow.hour, minute: scheduleRow.minute, weekDay: scheduleRow.weekDay, monthDay: scheduleRow.monthDay, keepCount: scheduleRow.keepCount, format: scheduleRow.format, }) setSavedStorage(storageRow) setScheduleLastError(scheduleRow.lastError ?? null) setStorage((prev) => ({ ...prev, provider: storageRow.provider, s3Endpoint: storageRow.s3Endpoint, s3Region: storageRow.s3Region, s3Bucket: storageRow.s3Bucket, s3Prefix: storageRow.s3Prefix, s3AccessKeyId: storageRow.s3AccessKeyId, s3SecretAccessKey: "", s3ForcePathStyle: storageRow.s3ForcePathStyle, keepLocalCopy: storageRow.keepLocalCopy, })) const serverIds = scheduleRow.serverIds.length > 0 ? scheduleRow.serverIds : mappedServers.map((s) => s.id) setSelectedServers(new Set(serverIds.filter((id) => mappedServers.some((s) => s.id === id)))) } catch (e) { setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных") } finally { setLoading(false) } }, [backendUrl, mapApiBackupToUi]) useEffect(() => { void loadLive() }, [loadLive]) async function handleSave() { if (scheduleSaveBusy) return setScheduleSaveBusy(true) setOpError(null) try { await putBackupScheduleSettings(backendUrl, { enabled: schedule.enabled, frequency: schedule.frequency, hour: schedule.hour, minute: schedule.minute, weekDay: schedule.weekDay, monthDay: schedule.monthDay, keepCount: schedule.keepCount, format: "rsc", serverIds: [...selectedServers], }) const saved = await putBackupStorageSettings(backendUrl, { provider: storage.provider, s3Endpoint: storage.s3Endpoint, s3Region: storage.s3Region, s3Bucket: storage.s3Bucket, s3Prefix: storage.s3Prefix, s3AccessKeyId: storage.s3AccessKeyId, s3SecretAccessKey: storage.s3SecretAccessKey || undefined, s3ForcePathStyle: storage.s3ForcePathStyle, keepLocalCopy: storage.keepLocalCopy, }) setSavedStorage(saved) setStorage((prev) => ({ ...prev, s3SecretAccessKey: "" })) toast.success("Настройки сохранены") } catch (e) { setOpError(e instanceof Error ? e.message : "Ошибка сохранения настроек") } finally { setScheduleSaveBusy(false) } } async function handleTestStorage() { setTestBusy(true) try { await putBackupStorageSettings(backendUrl, { provider: storage.provider, s3Endpoint: storage.s3Endpoint, s3Region: storage.s3Region, s3Bucket: storage.s3Bucket, s3Prefix: storage.s3Prefix, s3AccessKeyId: storage.s3AccessKeyId, s3SecretAccessKey: storage.s3SecretAccessKey || undefined, s3ForcePathStyle: storage.s3ForcePathStyle, keepLocalCopy: storage.keepLocalCopy, }) const result = await testBackupStorage(backendUrl) setSavedStorage(result) toast.success("S3 доступен") } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка проверки S3") try { setSavedStorage(await getBackupStorageSettings(backendUrl)) } catch { /* ignore */ } } finally { setTestBusy(false) } } async function handleSyncStorage() { setSyncBusy(true) try { const result = await syncBackupsFromStorage(backendUrl) toast.success(`Импортировано: ${result.imported}, пропущено: ${result.skipped}`) await loadLive() } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка синхронизации") } finally { setSyncBusy(false) } } async function handleManualBackup() { if (manualServers.size === 0 || opBusy) return setOpError(null) try { const res = await createBackupsAsync(backendUrl, { serverIds: [...manualServers], notes: manualNotes || undefined, }) setBackupJobId(res.jobId) setManualOpen(false) setManualServers(new Set()) setManualNotes("") setTab("history") } catch (e) { setOpError(e instanceof Error ? e.message : "Ошибка создания бэкапа") } } useEffect(() => { if (!backupJobId) return toast.info("Бэкап выполняется в фоне...") let cancelled = false const timer = setInterval(() => { void (async () => { try { const job = await getBackupJob(backendUrl, backupJobId) if (cancelled) return if (job.status === "done") { clearInterval(timer) setBackupJobId(null) if (job.failures.length > 0) { setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`) } else { toast.success("Бэкап успешно завершён") } await loadLive() } else if (job.status === "failed") { clearInterval(timer) setBackupJobId(null) setOpError(job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ") || "Ошибка фоновой задачи бэкапа") await loadLive() } } catch (e) { clearInterval(timer) setBackupJobId(null) setOpError(e instanceof Error ? e.message : "Ошибка опроса задачи бэкапа") } })() }, 1200) return () => { cancelled = true clearInterval(timer) } }, [backendUrl, backupJobId, loadLive]) useEffect(() => { if (opError) toast.error(opError) }, [opError]) async function handleDelete() { if (!deleteTarget) return setOpBusy(true) setOpError(null) try { await deleteBackup(backendUrl, deleteTarget.id) setDeleteTarget(null) await loadLive() } catch (e) { setOpError(e instanceof Error ? e.message : "Ошибка удаления бэкапа") } finally { setOpBusy(false) } } async function handleRestore() { if (!restoreTarget) return setOpBusy(true) setOpError(null) try { await restoreBackup(backendUrl, restoreTarget.id) toast.success(`Конфигурация восстановлена на ${restoreTarget.server}`) setRestoreTarget(null) } catch (e) { setOpError(e instanceof Error ? e.message : "Ошибка восстановления") } finally { setOpBusy(false) } } async function handleDownload(id: string, fallbackFilename: string) { const res = await requestBlob(backendUrl, `/api/backups/${id}/download`) const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = fallbackFilename a.click() URL.revokeObjectURL(url) } 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 = (() => { if (!schedule.enabled) return "Расписание отключено" const t = `${String(schedule.hour).padStart(2, "0")}:${String(schedule.minute).padStart(2, "0")}` if (schedule.frequency === "daily") return `Каждый день в ${t}` if (schedule.frequency === "weekly") return `Каждую неделю (${WEEK_DAYS[schedule.weekDay]}) в ${t}` return `${schedule.monthDay}-го числа каждого месяца в ${t}` })() const destinationLabel = storage.provider === "s3" ? (storage.keepLocalCopy ? "локально и в S3" : "в S3") : "локально (storage/backups)" const statusDetail = [ backupJobId ? null : `${destinationLabel} · ${selectedServers.size} из ${liveServers.length} серверов`, scheduleLastError, savedStorage?.lastTestError, ].filter(Boolean).join(" · ") const alertVariant = backupJobId ? "info" : opError || scheduleLastError || savedStorage?.lastTestError ? "warning" : "default" function openCreate(all: boolean) { setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set()) setManualNotes("") setManualStep(1) setManualOpen(true) } return (
} />
, iconClassName: "text-muted-foreground", }, { id: "auto", label: "Авто", value: autoCount, icon: , iconClassName: "text-info", }, { id: "manual", label: "Вручную", value: manualCount, icon: , iconClassName: "text-muted-foreground", }, { id: "s3", label: "В S3", value: s3Count, icon: , iconClassName: "text-primary", }, ]} /> {backupJobId ? "Задача выполняется" : schedSummary} {backupJobId ? "Снимок конфигурации снимается в фоне. Список обновится после завершения." : statusDetail} setTab(String(v) as PageTab)} className="gap-5" > История бэкапов Настройки openCreate(false)} /> { setSelectedServers((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) }} onSelectAll={() => setSelectedServers(new Set(liveServers.map((s) => s.id)))} onClearServers={() => setSelectedServers(new Set())} onSave={handleSave} onTest={handleTestStorage} onSync={handleSyncStorage} saveBusy={scheduleSaveBusy} testBusy={testBusy} syncBusy={syncBusy} />
{ 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={manualNotes} onNotesChange={setManualNotes} destinationLabel={destinationLabel} busy={backupJobId !== null} onSubmit={handleManualBackup} /> void handleDelete()} onCancel={() => setDeleteTarget(null)} /> void handleRestore()} onCancel={() => setRestoreTarget(null)} />
) }