"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { PageHeader } from "@/components/page-header" import { DataPageToolbar } from "@/components/data-page-toolbar" import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid" import { FileImportDialog } from "@/components/file-import-dialog" import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit" import { StatusBadge } from "@/components/status-badge" import type { Backup, Server } from "@/lib/data" import { Frame, FramePanel } from "@/components/reui/frame" import { IconTile } from "@/components/reui/icon-tile" import { OpsPanel } from "@/components/ops-panel" import { DataPageCard } from "@/components/data-page-card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, SheetClose, } from "@/components/ui/sheet" import { RefreshCwIcon, PlusIcon, DownloadIcon, Trash2Icon, HardDriveIcon, ClockIcon, ServerIcon, FolderIcon, } from "lucide-react" import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" import { listServers } from "@/shared/api/servers" import { toFrontendServer } from "@/entities/server/model/mappers" import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups" import { toast } from "sonner" import { Stepper, StepperContent, StepperIndicator, StepperItem, StepperNav, StepperPanel, StepperSeparator, StepperTitle, StepperTrigger, } from "@/components/reui/stepper" // ─── types ──────────────────────────────────────────────────────────────────── type PageTab = "history" | "settings" type KindFilter = "all" | "auto" | "manual" type BackupFreq = "daily" | "weekly" | "monthly" type StorageType = "local" | "ftp" | "scp" | "smb" type BackupFormat = "rsc" | "backup" const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] // ─── defaults ───────────────────────────────────────────────────────────────── const defaultSchedule = { enabled: true, frequency: "daily" as BackupFreq, hour: 3, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "rsc" as BackupFormat, } const defaultStorage = { type: "local" as StorageType, localPath: "/var/backup/mikrotik", host: "", port: "", username: "", password: "", remotePath: "/mikrotik-backups", share: "backups", showPassword: false, } // ════════════════════════════════════════════════════════════════════════════ export default function BackupsPage() { const { backendUrl } = useDataSource() // ── state ────────────────────────────────────────────────────────────── const [tab, setTab] = useState("history") const [backupList, setBackupList] = useState([]) const [kindFilter, setKindFilter] = useState("all") 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) // Schedule const [schedule, setSchedule] = useState(defaultSchedule) const setSched = (k: K, v: (typeof defaultSchedule)[K]) => setSchedule((s) => ({ ...s, [k]: v })) // Storage const [storage, setStorage] = useState(defaultStorage) const setStore = (k: K, v: (typeof defaultStorage)[K]) => setStorage((s) => ({ ...s, [k]: v })) // Server selection (all enabled by default) const [selectedServers, setSelectedServers] = useState>( new Set() ) function toggleServer(id: string) { setSelectedServers((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id); else next.add(id) return next }) } function handleSave() { if (scheduleSaveBusy) return setScheduleSaveBusy(true) setOpError(null) void (async () => { 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: schedule.format, serverIds: [...selectedServers], }) toast.success("Настройки сохранены") } catch (e) { setOpError(e instanceof Error ? e.message : "Ошибка сохранения расписания") } finally { setScheduleSaveBusy(false) } })() } // Manual backup sheet const [manualOpen, setManualOpen] = useState(false) const [manualStep, setManualStep] = useState(1) const [manualServers, setManualServers] = useState>(new Set()) const [manualNotes, setManualNotes] = useState("") const [restoreOpen, setRestoreOpen] = useState(false) const [restoreTarget, setRestoreTarget] = useState(null) function toggleManualServer(id: string) { setManualServers((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id); else next.add(id) return next }) } const mapApiBackupToUi = useCallback((b: BackupItem): Backup => { const kb = Math.max(1, Math.round(b.sizeBytes / 1024)) return { id: b.id, server: b.serverName, filename: b.filename, size: `${kb} КБ`, created: new Date(b.createdAt).toLocaleString("ru-RU"), kind: b.kind, notes: b.notes ?? "", } }, []) const loadLive = useCallback(async () => { setLoading(true) setOpError(null) try { const [serversRows, backupsRows, scheduleRow] = await Promise.all([ listServers(backendUrl), listBackups(backendUrl), getBackupScheduleSettings(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, }) 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 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]) // Delete backup async function handleDelete(id: string) { setOpBusy(true) setOpError(null) try { await deleteBackup(backendUrl, id) await loadLive() } catch (e) { setOpError(e instanceof Error ? e.message : "Ошибка удаления бэкапа") } finally { setOpBusy(false) } } async function handleDownload(id: string, fallbackFilename: string) { const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`) if (!res.ok) throw new Error("Не удалось скачать файл") 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) } // ── derived ──────────────────────────────────────────────────────────── const filtered = useMemo(() => backupList.filter((b) => kindFilter === "all" || b.kind === kindFilter), [backupList, kindFilter] ) const autoCount = backupList.filter((b) => b.kind === "auto").length const manualCount = backupList.filter((b) => b.kind === "manual").length const serverCount = new Set(backupList.map((b) => b.server)).size // Schedule summary string 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}` })() // Storage path summary const pathSummary = storage.type === "local" ? storage.localPath || "/var/backup/mikrotik" : `${storage.type.toUpperCase()}://${storage.host || "host"}${storage.remotePath || "/"}` // ── render ───────────────────────────────────────────────────────────── return (
} />
{/* Stats */}
{[ { label: "Всего бэкапов", value: backupList.length, icon: }, { label: "Авто", value: autoCount, icon: }, { label: "Вручную", value: manualCount, icon: }, { label: "Серверов охвачено",value: serverCount, icon: }, ].map((s) => (

{s.label}

{s.value}

))}
{/* Info bar */}
{schedSummary} {pathSummary} {selectedServers.size} из {liveServers.length} серверов
{/* Tabs */}
{([ { id: "history", label: "История бэкапов" }, { id: "settings", label: "Настройки" }, ] as { id: PageTab; label: string }[]).map((t) => ( ))}
{/* ── История ──────────────────────────────────────────────────── */} {tab === "history" && ( { setRestoreTarget(b) setRestoreOpen(true) }} onDelete={handleDelete} /> )} {/* ── Настройки ────────────────────────────────────────────────── */} {tab === "settings" && (
{/* Расписание */}

Автоматический бэкап

Создавать бэкапы по расписанию

setSched("enabled", v)} />
setSched("frequency", v)} options={[ { value: "daily", label: "Ежедневно" }, { value: "weekly", label: "Еженедельно" }, { value: "monthly", label: "Ежемесячно" }, ]} /> {schedule.frequency === "weekly" && (
{WEEK_DAYS.map((d, i) => ( ))}
)} {schedule.frequency === "monthly" && ( setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} /> )}
setSched("hour", Math.min(23, Math.max(0, Number(e.target.value))))} />
:
{[0, 15, 30, 45].map((m) => ( ))}
setSched("keepCount", Math.max(1, Number(e.target.value)))} /> setSched("format", v)} options={[ { value: "rsc", label: ".rsc" }, { value: "backup", label: ".backup" }, ]} />
{/* Хранилище */} setStore("type", v)} options={[ { value: "local", label: "Локально" }, { value: "ftp", label: "FTP" }, { value: "scp", label: "SCP" }, { value: "smb", label: "SMB" }, ]} /> {storage.type === "local" && ( setStore("localPath", e.target.value)} /> )} {storage.type !== "local" && ( <>
setStore("host", e.target.value)} />
setStore("port", e.target.value)} />
{storage.type === "smb" && ( setStore("share", e.target.value)} /> )}
setStore("username", e.target.value)} />
setStore("password", e.target.value)} />
setStore("remotePath", e.target.value)} /> )} {/* Preview */}

Путь сохранения

{pathSummary}

{storage.type !== "local" && (

Файлы: {pathSummary}/{""}_{"{date}"}.{schedule.format}

)} {storage.type === "local" && (

Файлы: {storage.localPath || "/var/backup/mikrotik"}/{""}_{"{date}"}.{schedule.format}

)}
{/* Серверы */} ·
} contentClassName="px-5 py-5 flex flex-col gap-4" >
{liveServers.map((s) => { const checked = selectedServers.has(s.id) return ( ) })}

Выбрано {selectedServers.size} из {liveServers.length} серверов

{/* Save button */}
)}
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */} { setManualOpen(v); if (!v) setManualStep(1) }}> Новый бэкап Снять конфигурацию вручную с выбранных серверов 1 Серверы 2 Заметка 3 Подтверждение

Выберите серверы

·
{liveServers.map((s) => { const checked = manualServers.has(s.id) return ( ) })}
setManualNotes(e.target.value)} />

Будет создан бэкап для {manualServers.size} серверов.

{manualNotes && (

Заметка: {manualNotes}

)}
}>Отмена {manualStep > 1 && ( )} {manualStep < 3 ? ( ) : ( )}
{ toast.success(`Файл ${files[0]?.name} подготовлен к восстановлению на ${restoreTarget?.server ?? "сервер"}`) }} /> ) }