Files
MikrotikManager/app/(main)/backups/page.tsx
T
2026-05-02 01:17:08 +07:00

681 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { StatusBadge } from "@/components/status-badge"
import { backups as initialBackups, servers } from "@/lib/data"
import type { Backup } 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, CheckCircleIcon,
FolderIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
// ─── 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>
)
}
// ─── 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() {
// ── state ──────────────────────────────────────────────────────────────
const [tab, setTab] = useState<PageTab>("history")
const [backupList, setBackupList] = useState<Backup[]>(initialBackups)
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
// 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(servers.map((s) => s.id))
)
function toggleServer(id: string) {
setSelectedServers((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
// Settings saved flash
const [saved, setSaved] = useState(false)
function handleSave() {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
// Manual backup sheet
const [manualOpen, setManualOpen] = useState(false)
const [manualServers, setManualServers] = useState<Set<string>>(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
})
}
function handleManualBackup() {
const now = new Date()
const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}-${String(now.getDate()).padStart(2,"0")}_${String(now.getHours()).padStart(2,"0")}-${String(now.getMinutes()).padStart(2,"0")}`
const newBackups: Backup[] = Array.from(manualServers).map((sid, i) => {
const sv = servers.find((s) => s.id === sid)!
return {
id: `b${Date.now()}${i}`,
server: sv.name,
filename: `${sv.name}_${ts}_manual.rsc`,
size: `${Math.floor(60 + Math.random()*80)} КБ`,
created: "Только что",
kind: "manual",
notes: manualNotes || "",
}
})
setBackupList((list) => [...newBackups, ...list])
setManualOpen(false)
setManualServers(new Set())
setManualNotes("")
setTab("history")
}
// Delete backup
function handleDelete(id: string) {
setBackupList((list) => list.filter((b) => b.id !== id))
}
// ── 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(servers.map(s=>s.id))); setManualOpen(true) }}>
<RefreshCwIcon className="size-4" />Снять со всех
</Button>
<Button size="sm" onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}>
<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} из {servers.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>
<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="Скачать">
<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={() => handleDelete(b.id)}>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</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>
<Toggle 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="Частота">
<SegmentedControl
value={schedule.frequency}
onChange={(v) => setSched("frequency", v)}
options={[
{ value: "daily", label: "Ежедневно" },
{ value: "weekly", label: "Еженедельно" },
{ value: "monthly", label: "Ежемесячно" },
]}
/>
</Field>
{schedule.frequency === "weekly" && (
<Field 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>
</Field>
)}
{schedule.frequency === "monthly" && (
<Field label="День месяца" hint="128">
<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>
)}
<Field 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>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field 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="Формат файла">
<SegmentedControl
value={schedule.format}
onChange={(v) => setSched("format", v)}
options={[
{ value: "rsc", label: ".rsc" },
{ value: "backup", label: ".backup" },
]}
/>
</Field>
</div>
</div>
</CardContent>
</Card>
{/* Хранилище */}
<Card>
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
<Field 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" },
]}
/>
</Field>
{storage.type === "local" && (
<Field label="Путь сохранения" hint="Директория на сервере приложения">
<Input className="font-mono" placeholder="/var/backup/mikrotik"
value={storage.localPath}
onChange={(e) => setStore("localPath", e.target.value)} />
</Field>
)}
{storage.type !== "local" && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Field label="Хост">
<Input className="font-mono" placeholder="192.168.1.100"
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
</Field>
</div>
<Field 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>
</div>
{storage.type === "smb" && (
<Field label="Общая папка (Share)">
<Input className="font-mono" placeholder="backups"
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Пользователь">
<Input className="font-mono" placeholder="backup-user"
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
</Field>
<Field 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>
</Field>
</div>
<Field label="Удалённый путь">
<Input className="font-mono" placeholder="/mikrotik-backups"
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
</Field>
</>
)}
{/* 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(servers.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">
{servers.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} из {servers.length} серверов
</p>
</CardContent>
</Card>
{/* Save button */}
<div className="lg:col-span-2 flex items-center gap-3">
<Button onClick={handleSave} className="gap-2">
Сохранить настройки
</Button>
{saved && (
<span className="flex items-center gap-1.5 text-sm text-emerald-500">
<CheckCircleIcon className="size-4" />
Настройки сохранены
</span>
)}
</div>
</div>
)}
</div>
</div>
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
<Sheet open={manualOpen} onOpenChange={setManualOpen}>
<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(servers.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>
{servers.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>
)
})}
</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>
<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}
onClick={handleManualBackup}>
Снять бэкап ({manualServers.size})
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}