Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
806 lines
37 KiB
TypeScript
806 lines
37 KiB
TypeScript
"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, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||
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, 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<PageTab>("history")
|
||
const [backupList, setBackupList] = useState<Backup[]>([])
|
||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [opBusy, setOpBusy] = useState(false)
|
||
const [opError, setOpError] = useState<string | null>(null)
|
||
const [backupJobId, setBackupJobId] = useState<string | null>(null)
|
||
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
||
|
||
// Schedule
|
||
const [schedule, setSchedule] = useState(defaultSchedule)
|
||
const setSched = <K extends keyof typeof defaultSchedule>(k: K, v: (typeof defaultSchedule)[K]) =>
|
||
setSchedule((s) => ({ ...s, [k]: v }))
|
||
|
||
// Storage
|
||
const [storage, setStorage] = useState(defaultStorage)
|
||
const setStore = <K extends keyof typeof defaultStorage>(k: K, v: (typeof defaultStorage)[K]) =>
|
||
setStorage((s) => ({ ...s, [k]: v }))
|
||
|
||
// Server selection (all enabled by default)
|
||
const [selectedServers, setSelectedServers] = useState<Set<string>>(
|
||
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<Set<string>>(new Set())
|
||
const [manualNotes, setManualNotes] = useState("")
|
||
const [restoreOpen, setRestoreOpen] = useState(false)
|
||
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(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 (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
|
||
actions={
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setManualServers(new Set(liveServers.map((s) => s.id)))
|
||
setManualOpen(true)
|
||
}}
|
||
disabled={loading}
|
||
>
|
||
<RefreshCwIcon className="size-4" />Снять со всех
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualStep(1); setManualOpen(true) }}
|
||
disabled={loading}
|
||
>
|
||
<PlusIcon className="size-4" />Новый бэкап
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="flex flex-col gap-5">
|
||
|
||
{/* Stats */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{[
|
||
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> },
|
||
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> },
|
||
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
|
||
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
|
||
].map((s) => (
|
||
<Card key={s.label}>
|
||
<CardContent className="px-5 py-4 flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||
</div>
|
||
<span className="text-muted-foreground/40">{s.icon}</span>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* Info bar */}
|
||
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
|
||
<span className="flex items-center gap-1.5">
|
||
<ClockIcon className="size-3" />
|
||
{schedSummary}
|
||
</span>
|
||
<span className="h-3 w-px bg-border" />
|
||
<span className="flex items-center gap-1.5">
|
||
<FolderIcon className="size-3" />
|
||
{pathSummary}
|
||
</span>
|
||
<span className="h-3 w-px bg-border" />
|
||
<span className="flex items-center gap-1.5">
|
||
<ServerIcon className="size-3" />
|
||
{selectedServers.size} из {liveServers.length} серверов
|
||
</span>
|
||
<button onClick={() => setTab("settings")}
|
||
className="ml-auto text-xs text-primary hover:underline">
|
||
Изменить настройки →
|
||
</button>
|
||
</div>
|
||
{/* Tabs */}
|
||
<div className="flex items-center gap-1 border-b border-border">
|
||
{([
|
||
{ id: "history", label: "История бэкапов" },
|
||
{ id: "settings", label: "Настройки" },
|
||
] as { id: PageTab; label: string }[]).map((t) => (
|
||
<button key={t.id} onClick={() => setTab(t.id)}
|
||
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${tab === t.id ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── История ──────────────────────────────────────────────────── */}
|
||
{tab === "history" && (
|
||
<Card>
|
||
<DataPageToolbar
|
||
segmented={{
|
||
value: kindFilter,
|
||
onChange: setKindFilter,
|
||
options: [
|
||
{ value: "all", label: "Все", count: backupList.length },
|
||
{ value: "auto", label: "Авто", count: autoCount },
|
||
{ value: "manual", label: "Вручную", count: manualCount },
|
||
],
|
||
}}
|
||
countLabel={`${filtered.length} бэкапов`}
|
||
/>
|
||
<BackupsDataGrid
|
||
backups={filtered}
|
||
onDownload={handleDownload}
|
||
onRestore={(b) => {
|
||
setRestoreTarget(b)
|
||
setRestoreOpen(true)
|
||
}}
|
||
onDelete={handleDelete}
|
||
/>
|
||
</Card>
|
||
)}
|
||
|
||
{/* ── Настройки ────────────────────────────────────────────────── */}
|
||
{tab === "settings" && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||
|
||
{/* Расписание */}
|
||
<Card>
|
||
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
||
<SectionTitle icon={<ClockIcon className="size-3.5" />}>Расписание</SectionTitle>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">Автоматический бэкап</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
||
</div>
|
||
<FormToggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
|
||
</div>
|
||
|
||
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
|
||
<FormField label="Частота">
|
||
<SegmentedControl
|
||
value={schedule.frequency}
|
||
onChange={(v) => setSched("frequency", v)}
|
||
options={[
|
||
{ value: "daily", label: "Ежедневно" },
|
||
{ value: "weekly", label: "Еженедельно" },
|
||
{ value: "monthly", label: "Ежемесячно" },
|
||
]}
|
||
/>
|
||
</FormField>
|
||
|
||
{schedule.frequency === "weekly" && (
|
||
<FormField label="День недели">
|
||
<div className="flex gap-1">
|
||
{WEEK_DAYS.map((d, i) => (
|
||
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
|
||
className={cn(
|
||
"w-9 h-9 rounded text-sm font-medium border transition-colors",
|
||
schedule.weekDay === i
|
||
? "bg-primary text-primary-foreground border-primary"
|
||
: "border-border text-muted-foreground hover:text-foreground"
|
||
)}>
|
||
{d}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</FormField>
|
||
)}
|
||
|
||
{schedule.frequency === "monthly" && (
|
||
<FormField label="День месяца" hint="1–28">
|
||
<Input type="number" min={1} max={28} className="font-mono w-24"
|
||
value={schedule.monthDay}
|
||
onChange={(e) => setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} />
|
||
</FormField>
|
||
)}
|
||
|
||
<FormField label="Время запуска">
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative">
|
||
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
|
||
value={String(schedule.hour).padStart(2, "0")}
|
||
onChange={(e) => setSched("hour", Math.min(23, Math.max(0, Number(e.target.value))))} />
|
||
</div>
|
||
<span className="text-muted-foreground font-mono text-lg">:</span>
|
||
<div className="flex gap-1">
|
||
{[0, 15, 30, 45].map((m) => (
|
||
<button key={m} type="button" onClick={() => setSched("minute", m)}
|
||
className={cn(
|
||
"px-2.5 py-1.5 rounded text-xs font-mono border transition-colors",
|
||
schedule.minute === m
|
||
? "bg-primary text-primary-foreground border-primary"
|
||
: "border-border text-muted-foreground hover:text-foreground"
|
||
)}>
|
||
{String(m).padStart(2, "0")}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</FormField>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<FormField label="Хранить бэкапов" hint="На каждый сервер">
|
||
<Input type="number" min={1} max={90} className="font-mono"
|
||
value={schedule.keepCount}
|
||
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
|
||
</FormField>
|
||
<FormField label="Формат файла">
|
||
<SegmentedControl
|
||
value={schedule.format}
|
||
onChange={(v) => setSched("format", v)}
|
||
options={[
|
||
{ value: "rsc", label: ".rsc" },
|
||
{ value: "backup", label: ".backup" },
|
||
]}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Хранилище */}
|
||
<Card>
|
||
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
||
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
|
||
|
||
<FormField label="Тип хранилища">
|
||
<SegmentedControl
|
||
value={storage.type}
|
||
onChange={(v) => setStore("type", v)}
|
||
options={[
|
||
{ value: "local", label: "Локально" },
|
||
{ value: "ftp", label: "FTP" },
|
||
{ value: "scp", label: "SCP" },
|
||
{ value: "smb", label: "SMB" },
|
||
]}
|
||
/>
|
||
</FormField>
|
||
|
||
{storage.type === "local" && (
|
||
<FormField label="Путь сохранения" hint="Директория на сервере приложения">
|
||
<Input className="font-mono" placeholder="/var/backup/mikrotik"
|
||
value={storage.localPath}
|
||
onChange={(e) => setStore("localPath", e.target.value)} />
|
||
</FormField>
|
||
)}
|
||
|
||
{storage.type !== "local" && (
|
||
<>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="col-span-2">
|
||
<FormField label="Хост">
|
||
<Input className="font-mono" placeholder="192.168.1.100"
|
||
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
<FormField label="Порт">
|
||
<Input className="font-mono"
|
||
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
|
||
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
|
||
{storage.type === "smb" && (
|
||
<FormField label="Общая папка (Share)">
|
||
<Input className="font-mono" placeholder="backups"
|
||
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
|
||
</FormField>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<FormField label="Пользователь">
|
||
<Input className="font-mono" placeholder="backup-user"
|
||
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
|
||
</FormField>
|
||
<FormField label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
||
<div className="relative">
|
||
<Input
|
||
type={storage.showPassword ? "text" : "password"}
|
||
className="font-mono pr-8"
|
||
placeholder="••••••••"
|
||
value={storage.password}
|
||
onChange={(e) => setStore("password", e.target.value)}
|
||
/>
|
||
<button type="button"
|
||
onClick={() => setStore("showPassword", !storage.showPassword)}
|
||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-xs">
|
||
{storage.showPassword ? "скрыть" : "показ"}
|
||
</button>
|
||
</div>
|
||
</FormField>
|
||
</div>
|
||
|
||
<FormField label="Удалённый путь">
|
||
<Input className="font-mono" placeholder="/mikrotik-backups"
|
||
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
|
||
</FormField>
|
||
</>
|
||
)}
|
||
|
||
{/* Preview */}
|
||
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
||
<p className="font-medium text-foreground mb-1">Путь сохранения</p>
|
||
<p className="font-mono text-foreground break-all">{pathSummary}</p>
|
||
{storage.type !== "local" && (
|
||
<p className="mt-1">Файлы: <span className="font-mono text-foreground">{pathSummary}/{"<server-name>"}_{"{date}"}.{schedule.format}</span></p>
|
||
)}
|
||
{storage.type === "local" && (
|
||
<p className="mt-1">Файлы: <span className="font-mono text-foreground">{storage.localPath || "/var/backup/mikrotik"}/{"<server-name>"}_{"{date}"}.{schedule.format}</span></p>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Серверы */}
|
||
<Card className="lg:col-span-2">
|
||
<CardContent className="px-5 py-5 flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<SectionTitle icon={<ServerIcon className="size-3.5" />}>Серверы для бэкапа</SectionTitle>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<button type="button" onClick={() => setSelectedServers(new Set(liveServers.map(s => s.id)))}
|
||
className="text-xs text-primary hover:underline">Выбрать все</button>
|
||
<span className="text-border">·</span>
|
||
<button type="button" onClick={() => setSelectedServers(new Set())}
|
||
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
||
{liveServers.map((s) => {
|
||
const checked = selectedServers.has(s.id)
|
||
return (
|
||
<button key={s.id} type="button" onClick={() => toggleServer(s.id)}
|
||
className={cn(
|
||
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
|
||
checked
|
||
? "border-primary/40 bg-primary/5"
|
||
: "border-border hover:border-border/80 hover:bg-muted/40"
|
||
)}>
|
||
<div className={cn(
|
||
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||
checked ? "bg-primary border-primary" : "border-border"
|
||
)}>
|
||
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium truncate">{s.name}</p>
|
||
<div className="flex items-center gap-1.5 mt-0.5">
|
||
<span className="text-xs text-muted-foreground">{s.site}</span>
|
||
<StatusBadge status={s.status} />
|
||
</div>
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<p className="text-xs text-muted-foreground">
|
||
Выбрано {selectedServers.size} из {liveServers.length} серверов
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Save button */}
|
||
<div className="lg:col-span-2 flex items-center gap-3">
|
||
<Button onClick={handleSave} className="gap-2" disabled={scheduleSaveBusy}>
|
||
Сохранить настройки
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
|
||
<Sheet open={manualOpen} onOpenChange={(v) => { setManualOpen(v); if (!v) setManualStep(1) }}>
|
||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||
<SheetTitle>Новый бэкап</SheetTitle>
|
||
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<Stepper value={manualStep} onValueChange={setManualStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||
<StepperNav className="mb-5">
|
||
<StepperItem step={1}>
|
||
<StepperTrigger>
|
||
<StepperIndicator>1</StepperIndicator>
|
||
<StepperTitle className="sr-only">Серверы</StepperTitle>
|
||
</StepperTrigger>
|
||
<StepperSeparator />
|
||
</StepperItem>
|
||
<StepperItem step={2}>
|
||
<StepperTrigger>
|
||
<StepperIndicator>2</StepperIndicator>
|
||
<StepperTitle className="sr-only">Заметка</StepperTitle>
|
||
</StepperTrigger>
|
||
<StepperSeparator />
|
||
</StepperItem>
|
||
<StepperItem step={3}>
|
||
<StepperTrigger>
|
||
<StepperIndicator>3</StepperIndicator>
|
||
<StepperTitle className="sr-only">Подтверждение</StepperTitle>
|
||
</StepperTrigger>
|
||
</StepperItem>
|
||
</StepperNav>
|
||
<StepperPanel className="flex-1 overflow-y-auto">
|
||
<StepperContent value={1} className="flex flex-col gap-3">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<p className="text-sm font-medium">Выберите серверы</p>
|
||
<div className="flex items-center gap-2">
|
||
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
|
||
className="text-xs text-primary hover:underline">Все</button>
|
||
<span className="text-border">·</span>
|
||
<button type="button" onClick={() => setManualServers(new Set())}
|
||
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
||
</div>
|
||
</div>
|
||
{liveServers.map((s) => {
|
||
const checked = manualServers.has(s.id)
|
||
return (
|
||
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
|
||
className={cn(
|
||
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
|
||
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
|
||
)}>
|
||
<div className={cn(
|
||
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||
checked ? "bg-primary border-primary" : "border-border"
|
||
)}>
|
||
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium">{s.name}</p>
|
||
<div className="flex items-center gap-1.5 mt-0.5">
|
||
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
|
||
<StatusBadge status={s.status} />
|
||
</div>
|
||
</div>
|
||
{s.status === "offline" && (
|
||
<span className="text-xs text-muted-foreground">недоступен</span>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</StepperContent>
|
||
<StepperContent value={2} className="flex flex-col gap-4">
|
||
<FormField label="Заметка">
|
||
<Input placeholder="Например: перед обновлением BGP"
|
||
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
|
||
</FormField>
|
||
</StepperContent>
|
||
<StepperContent value={3} className="flex flex-col gap-3 text-sm">
|
||
<p className="text-muted-foreground">
|
||
Будет создан бэкап для <strong className="text-foreground">{manualServers.size}</strong> серверов.
|
||
</p>
|
||
{manualNotes && (
|
||
<p className="text-muted-foreground">Заметка: {manualNotes}</p>
|
||
)}
|
||
</StepperContent>
|
||
</StepperPanel>
|
||
</Stepper>
|
||
|
||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||
{manualStep > 1 && (
|
||
<Button variant="outline" className="flex-1" onClick={() => setManualStep((s) => s - 1)}>
|
||
Назад
|
||
</Button>
|
||
)}
|
||
{manualStep < 3 ? (
|
||
<Button
|
||
className="flex-1"
|
||
disabled={manualStep === 1 && manualServers.size === 0}
|
||
onClick={() => setManualStep((s) => s + 1)}
|
||
>
|
||
Далее
|
||
</Button>
|
||
) : (
|
||
<Button className="flex-1"
|
||
disabled={manualServers.size === 0 || backupJobId !== null}
|
||
onClick={handleManualBackup}>
|
||
Снять бэкап ({manualServers.size})
|
||
</Button>
|
||
)}
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
|
||
<FileImportDialog
|
||
open={restoreOpen}
|
||
onOpenChange={setRestoreOpen}
|
||
title={restoreTarget ? `Восстановление: ${restoreTarget.filename}` : "Восстановление бэкапа"}
|
||
description="Выберите файл конфигурации для загрузки на роутер"
|
||
accept=".backup,.rsc,.zip"
|
||
onImport={async (files) => {
|
||
toast.success(`Файл ${files[0]?.name} подготовлен к восстановлению на ${restoreTarget?.server ?? "сервер"}`)
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|