"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { PageHeader } from "@/components/page-header" import { StatusBadge } from "@/components/status-badge" import type { Backup, Server } from "@/lib/data" import { Card, CardContent } from "@/components/ui/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, listBackups, type BackupItem } from "@/shared/api/backups" import { toast } from "sonner" // ─── small UI helpers ───────────────────────────────────────────────────────── function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ) } function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { return (
{icon} {children}
) } function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { return (
{children} {hint &&

{hint}

}
) } function SegmentedControl({ value, onChange, options }: { value: T; onChange: (v: T) => void; options: { value: T; label: string }[] }) { return (
{options.map((o) => ( ))}
) } // ─── 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) // 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() { toast.success("Настройки сохранены") } // Manual backup sheet const [manualOpen, setManualOpen] = useState(false) const [manualServers, setManualServers] = useState>(new Set()) const [manualNotes, setManualNotes] = useState("") 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] = await Promise.all([ listServers(backendUrl), listBackups(backendUrl), ]) const mappedServers = serversRows.map((s) => toFrontendServer(s)) setLiveServers(mappedServers) setBackupList(backupsRows.map(mapApiBackupToUi)) setSelectedServers(new Set(mappedServers.map((s) => s.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}

{s.icon}
))}
{/* Info bar */}
{schedSummary} {pathSummary} {selectedServers.size} из {liveServers.length} серверов
{/* Tabs */}
{([ { id: "history", label: "История бэкапов" }, { id: "settings", label: "Настройки" }, ] as { id: PageTab; label: string }[]).map((t) => ( ))}
{/* ── История ──────────────────────────────────────────────────── */} {tab === "history" && (
{([ { value: "all", label: "Все", count: backupList.length }, { value: "auto", label: "Авто", count: autoCount }, { value: "manual", label: "Вручную", count: manualCount }, ] as { value: KindFilter; label: string; count: number }[]).map((t) => ( ))}
{filtered.length} бэкапов
{filtered.length === 0 && ( )} {filtered.map((b) => ( ))}
Файл Сервер Размер Тип Заметки Создан
Нет бэкапов
{b.filename} {b.server} {b.size} {b.kind === "auto" ? "авто" : "вручную"} {b.notes || "—"} {b.created}
)} {/* ── Настройки ────────────────────────────────────────────────── */} {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}

)}
{/* Серверы */}
}>Серверы для бэкапа
·
{liveServers.map((s) => { const checked = selectedServers.has(s.id) return ( ) })}

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

{/* Save button */}
)}
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */} Новый бэкап Снять конфигурацию вручную с выбранных серверов

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

·
{liveServers.map((s) => { const checked = manualServers.has(s.id) return ( ) })}
setManualNotes(e.target.value)} />
}>Отмена
) }