fix(config): добавить новые зависимости и обновить конфигурацию компонентов
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
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
This commit is contained in:
+178
-202
@@ -2,6 +2,10 @@
|
||||
|
||||
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"
|
||||
@@ -22,52 +26,17 @@ 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"
|
||||
|
||||
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}>
|
||||
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
||||
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
||||
{options.map((o) => (
|
||||
<button key={o.value} type="button" onClick={() => onChange(o.value)}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import {
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -168,8 +137,11 @@ export default function BackupsPage() {
|
||||
|
||||
// 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)
|
||||
@@ -356,7 +328,7 @@ export default function BackupsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}
|
||||
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualStep(1); setManualOpen(true) }}
|
||||
disabled={loading}
|
||||
>
|
||||
<PlusIcon className="size-4" />Новый бэкап
|
||||
@@ -425,81 +397,27 @@ export default function BackupsPage() {
|
||||
{/* ── История ──────────────────────────────────────────────────── */}
|
||||
{tab === "history" && (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{([
|
||||
{ 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) => (
|
||||
<button key={t.value} onClick={() => setKindFilter(t.value)}
|
||||
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${kindFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
||||
{t.label}
|
||||
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} бэкапов</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3">Файл</th>
|
||||
<th className="text-left font-medium px-4 py-3">Сервер</th>
|
||||
<th className="text-left font-medium px-4 py-3">Размер</th>
|
||||
<th className="text-left font-medium px-4 py-3">Тип</th>
|
||||
<th className="text-left font-medium px-4 py-3">Заметки</th>
|
||||
<th className="text-left font-medium px-4 py-3">Создан</th>
|
||||
<th className="w-28 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-5 py-10 text-center text-sm text-muted-foreground">Нет бэкапов</td></tr>
|
||||
)}
|
||||
{filtered.map((b) => (
|
||||
<tr key={b.id} className="hover:bg-muted/40 transition-colors group">
|
||||
<td className="px-5 py-3 font-mono text-xs font-medium">{b.filename}</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{b.server}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{b.size}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn("text-xs px-2 py-0.5 rounded border font-medium",
|
||||
b.kind === "manual"
|
||||
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
||||
: "bg-muted text-muted-foreground border-border"
|
||||
)}>
|
||||
{b.kind === "auto" ? "авто" : "вручную"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{b.notes || "—"}</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
title="Скачать"
|
||||
onClick={() => void handleDownload(b.id, b.filename)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
|
||||
title="Удалить" onClick={() => void handleDelete(b.id)}>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -517,11 +435,11 @@ export default function BackupsPage() {
|
||||
<p className="text-sm font-medium">Автоматический бэкап</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
||||
</div>
|
||||
<Toggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
|
||||
<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")}>
|
||||
<Field label="Частота">
|
||||
<FormField label="Частота">
|
||||
<SegmentedControl
|
||||
value={schedule.frequency}
|
||||
onChange={(v) => setSched("frequency", v)}
|
||||
@@ -531,10 +449,10 @@ export default function BackupsPage() {
|
||||
{ value: "monthly", label: "Ежемесячно" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
{schedule.frequency === "weekly" && (
|
||||
<Field label="День недели">
|
||||
<FormField label="День недели">
|
||||
<div className="flex gap-1">
|
||||
{WEEK_DAYS.map((d, i) => (
|
||||
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
|
||||
@@ -548,18 +466,18 @@ export default function BackupsPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{schedule.frequency === "monthly" && (
|
||||
<Field label="День месяца" hint="1–28">
|
||||
<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))))} />
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<Field label="Время запуска">
|
||||
<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"
|
||||
@@ -581,15 +499,15 @@ export default function BackupsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Хранить бэкапов" hint="На каждый сервер">
|
||||
<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)))} />
|
||||
</Field>
|
||||
<Field label="Формат файла">
|
||||
</FormField>
|
||||
<FormField label="Формат файла">
|
||||
<SegmentedControl
|
||||
value={schedule.format}
|
||||
onChange={(v) => setSched("format", v)}
|
||||
@@ -598,7 +516,7 @@ export default function BackupsPage() {
|
||||
{ value: "backup", label: ".backup" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -609,7 +527,7 @@ export default function BackupsPage() {
|
||||
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
||||
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
|
||||
|
||||
<Field label="Тип хранилища">
|
||||
<FormField label="Тип хранилища">
|
||||
<SegmentedControl
|
||||
value={storage.type}
|
||||
onChange={(v) => setStore("type", v)}
|
||||
@@ -620,45 +538,45 @@ export default function BackupsPage() {
|
||||
{ value: "smb", label: "SMB" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
{storage.type === "local" && (
|
||||
<Field label="Путь сохранения" hint="Директория на сервере приложения">
|
||||
<FormField label="Путь сохранения" hint="Директория на сервере приложения">
|
||||
<Input className="font-mono" placeholder="/var/backup/mikrotik"
|
||||
value={storage.localPath}
|
||||
onChange={(e) => setStore("localPath", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{storage.type !== "local" && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Field label="Хост">
|
||||
<FormField label="Хост">
|
||||
<Input className="font-mono" placeholder="192.168.1.100"
|
||||
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
<Field label="Порт">
|
||||
<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)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{storage.type === "smb" && (
|
||||
<Field label="Общая папка (Share)">
|
||||
<FormField label="Общая папка (Share)">
|
||||
<Input className="font-mono" placeholder="backups"
|
||||
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Пользователь">
|
||||
<FormField label="Пользователь">
|
||||
<Input className="font-mono" placeholder="backup-user"
|
||||
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
|
||||
</Field>
|
||||
<Field label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
||||
</FormField>
|
||||
<FormField label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={storage.showPassword ? "text" : "password"}
|
||||
@@ -673,13 +591,13 @@ export default function BackupsPage() {
|
||||
{storage.showPassword ? "скрыть" : "показ"}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<Field label="Удалённый путь">
|
||||
<FormField label="Удалённый путь">
|
||||
<Input className="font-mono" placeholder="/mikrotik-backups"
|
||||
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -759,71 +677,129 @@ export default function BackupsPage() {
|
||||
</div>
|
||||
|
||||
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
|
||||
<Sheet open={manualOpen} onOpenChange={setManualOpen}>
|
||||
<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>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div 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>
|
||||
<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>
|
||||
</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} />
|
||||
{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>
|
||||
{s.status === "offline" && (
|
||||
<span className="text-xs text-muted-foreground">недоступен</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">Заметка</label>
|
||||
<Input placeholder="Например: перед обновлением BGP"
|
||||
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
|
||||
</div>
|
||||
</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>
|
||||
<Button className="flex-1"
|
||||
disabled={manualServers.size === 0 || backupJobId !== null}
|
||||
onClick={handleManualBackup}>
|
||||
Снять бэкап ({manualServers.size})
|
||||
</Button>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user