Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
817 lines
37 KiB
TypeScript
817 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, SegmentedControl } from "@/components/form-kit"
|
||
import { StatusBadge } from "@/components/status-badge"
|
||
import type { Backup, Server } from "@/lib/data"
|
||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||
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 { requestBlob } from "@/shared/api/http-client"
|
||
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 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)
|
||
}
|
||
|
||
// ── 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">
|
||
|
||
<KpiStatGrid
|
||
aria-label="Сводка бэкапов"
|
||
items={[
|
||
{
|
||
id: "all",
|
||
label: "Всего бэкапов",
|
||
value: backupList.length,
|
||
icon: <HardDriveIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "auto",
|
||
label: "Авто",
|
||
value: autoCount,
|
||
icon: <ClockIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
{
|
||
id: "manual",
|
||
label: "Вручную",
|
||
value: manualCount,
|
||
icon: <PlusIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "servers",
|
||
label: "Серверов охвачено",
|
||
value: serverCount,
|
||
icon: <ServerIcon className="size-4" />,
|
||
iconClassName: "text-primary",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{/* 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" && (
|
||
<DataPageCard>
|
||
<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}
|
||
/>
|
||
</DataPageCard>
|
||
)}
|
||
|
||
{/* ── Настройки ────────────────────────────────────────────────── */}
|
||
{tab === "settings" && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||
|
||
{/* Расписание */}
|
||
<OpsPanel title="Расписание" contentClassName="px-5 py-5 flex flex-col gap-5">
|
||
|
||
<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>
|
||
</OpsPanel>
|
||
|
||
{/* Хранилище */}
|
||
<OpsPanel title="Хранилище" contentClassName="px-5 py-5 flex flex-col gap-5">
|
||
|
||
<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>
|
||
</OpsPanel>
|
||
|
||
{/* Серверы */}
|
||
<OpsPanel
|
||
className="lg:col-span-2"
|
||
title="Серверы для бэкапа"
|
||
headerRight={
|
||
<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>
|
||
}
|
||
contentClassName="px-5 py-5 flex flex-col gap-4"
|
||
>
|
||
|
||
<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>
|
||
</OpsPanel>
|
||
|
||
{/* 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>
|
||
)
|
||
}
|