Init commit

This commit is contained in:
Denozordec
2026-05-02 01:17:08 +07:00
commit f3f831653f
104 changed files with 43827 additions and 0 deletions
+680
View File
@@ -0,0 +1,680 @@
"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>
)
}