feat(backups): добавить S3-хранилище и обновить экран бэкапов
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 4m37s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 16s

Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами.
Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-08 14:06:27 +07:00
co-authored by Cursor
parent 39c8ec4a02
commit cff26813b9
21 changed files with 2429 additions and 637 deletions
@@ -0,0 +1,97 @@
"use client"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import type { Backup } from "@/lib/data"
import { AlertCircleIcon, LoaderCircleIcon, TriangleAlertIcon } from "lucide-react"
export function BackupDeleteDialog({
backup,
busy,
onConfirm,
onCancel,
}: {
backup: Backup | null
busy: boolean
onConfirm: () => void
onCancel: () => void
}) {
return (
<AlertDialog open={Boolean(backup)} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<AlertCircleIcon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить бэкап?</AlertDialogTitle>
<AlertDialogDescription>
Файл <span className="font-mono text-foreground">{backup?.filename}</span> будет удалён
{backup?.storage === "s3" || backup?.storage === "both" ? " локально и из S3" : " с диска"}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={busy} onClick={onConfirm}>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export function BackupRestoreDialog({
backup,
busy,
onConfirm,
onCancel,
}: {
backup: Backup | null
busy: boolean
onConfirm: () => void
onCancel: () => void
}) {
return (
<AlertDialog open={Boolean(backup)} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-warning/10 text-warning">
<TriangleAlertIcon />
</AlertDialogMedia>
<AlertDialogTitle>Восстановить конфигурацию?</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-3">
<span>
Файл <span className="font-mono text-foreground">{backup?.filename}</span> будет загружен
на <span className="text-foreground">{backup?.server}</span> и импортирован.
</span>
<Alert variant="warning">
<TriangleAlertIcon />
<AlertTitle>Это изменит рабочую конфигурацию роутера</AlertTitle>
<AlertDescription>
Сессия может оборваться. Убедитесь, что выбран именно этот сервер и этот снимок.
</AlertDescription>
</Alert>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction disabled={busy} onClick={onConfirm}>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
Восстановить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
+199
View File
@@ -0,0 +1,199 @@
"use client"
import type { Server } from "@/lib/data"
import { FormField } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge"
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 {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import { cn } from "@/lib/utils"
export function BackupCreateSheet({
open,
onOpenChange,
step,
onStepChange,
servers,
selected,
onToggle,
onSelectAll,
onClear,
notes,
onNotesChange,
destinationLabel,
busy,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
step: number
onStepChange: (step: number) => void
servers: Server[]
selected: Set<string>
onToggle: (id: string) => void
onSelectAll: () => void
onClear: () => void
notes: string
onNotesChange: (value: string) => void
destinationLabel: string
busy: boolean
onSubmit: () => void
}) {
return (
<Sheet open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) onStepChange(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={step} onValueChange={onStepChange} 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={onSelectAll} className="text-xs text-primary hover:underline">
Все
</button>
<span className="text-border">·</span>
<button
type="button"
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Сбросить
</button>
</div>
</div>
{servers.map((s) => {
const checked = selected.has(s.id)
return (
<button
key={s.id}
type="button"
onClick={() => onToggle(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" aria-hidden="true">
<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={notes}
onChange={(e) => onNotesChange(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">{selected.size}</strong> серверов.
</p>
<p className="text-muted-foreground">
Куда сохранится: <strong className="text-foreground">{destinationLabel}</strong>
</p>
{notes ? <p className="text-muted-foreground">Заметка: {notes}</p> : null}
</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>
{step > 1 && (
<Button type="button" variant="outline" className="flex-1" onClick={() => onStepChange(step - 1)}>
Назад
</Button>
)}
{step < 3 ? (
<Button
type="button"
className="flex-1"
disabled={step === 1 && selected.size === 0}
onClick={() => onStepChange(step + 1)}
>
Далее
</Button>
) : (
<Button
type="button"
className="flex-1"
disabled={selected.size === 0 || busy}
onClick={onSubmit}
>
Снять бэкап ({selected.size})
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
)
}
+97
View File
@@ -0,0 +1,97 @@
"use client"
import type { Filter } from "@/components/reui/filters"
import type { Backup } from "@/lib/data"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import {
BACKUP_FILTER_ACCESSORS,
BACKUP_FILTER_FIELDS,
} from "@/lib/data-filters/backup-filter-fields"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
import { Button } from "@/components/ui/button"
import { PlusIcon } from "lucide-react"
type KindFilter = "all" | "auto" | "manual"
export function BackupsHistory({
backups,
kindFilter,
onKindFilterChange,
search,
onSearchChange,
filters,
onFiltersChange,
onDownload,
onRestore,
onDelete,
onCreate,
}: {
backups: Backup[]
kindFilter: KindFilter
onKindFilterChange: (value: KindFilter) => void
search: string
onSearchChange: (value: string) => void
filters: Filter[]
onFiltersChange: (filters: Filter[]) => void
onDownload: (id: string, filename: string) => void
onRestore: (backup: Backup) => void
onDelete: (backup: Backup) => void
onCreate: () => void
}) {
const autoCount = backups.filter((b) => b.kind === "auto").length
const manualCount = backups.filter((b) => b.kind === "manual").length
const byKind = kindFilter === "all" ? backups : backups.filter((b) => b.kind === kindFilter)
const q = search.trim().toLowerCase()
const searched = q
? byKind.filter((b) =>
[b.filename, b.server, b.notes].some((v) => v.toLowerCase().includes(q)),
)
: byKind
const filtered = applyReuiFilters(searched, filters, BACKUP_FILTER_ACCESSORS)
const isEmptyAll = backups.length === 0
return (
<DataPageCard>
<DataPageToolbar
segmented={{
value: kindFilter,
onChange: onKindFilterChange,
options: [
{ value: "all", label: "Все", count: backups.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
],
}}
filters={filters}
onFiltersChange={onFiltersChange}
filterFields={BACKUP_FILTER_FIELDS}
search={search}
onSearchChange={onSearchChange}
searchPlaceholder="Поиск по файлу, серверу, заметке…"
countLabel={`${filtered.length} бэкапов`}
/>
<BackupsDataGrid
backups={filtered}
onDownload={onDownload}
onRestore={onRestore}
onDelete={onDelete}
emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"}
emptyDescription={
isEmptyAll
? "Создайте первый бэкап вручную или настройте расписание"
: "Измените фильтры или поисковый запрос"
}
emptyAction={
isEmptyAll ? (
<Button type="button" size="sm" onClick={onCreate}>
<PlusIcon className="size-4" />
Новый бэкап
</Button>
) : null
}
/>
</DataPageCard>
)
}
+427
View File
@@ -0,0 +1,427 @@
"use client"
import type { Server } from "@/lib/data"
import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups"
import { Badge } from "@/components/reui/badge"
import { OpsPanel } from "@/components/ops-panel"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { LoaderCircleIcon } from "lucide-react"
export const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
export type BackupFreq = "daily" | "weekly" | "monthly"
export type StorageProvider = "local" | "s3"
export type BackupScheduleForm = {
enabled: boolean
frequency: BackupFreq
hour: number
minute: number
weekDay: number
monthDay: number
keepCount: number
format: "rsc" | "backup"
}
export type BackupStorageForm = {
provider: StorageProvider
s3Endpoint: string
s3Region: string
s3Bucket: string
s3Prefix: string
s3AccessKeyId: string
s3SecretAccessKey: string
s3ForcePathStyle: boolean
keepLocalCopy: boolean
showPassword: boolean
}
export const defaultSchedule: BackupScheduleForm = {
enabled: true,
frequency: "daily",
hour: 3,
minute: 0,
weekDay: 0,
monthDay: 1,
keepCount: 7,
format: "rsc",
}
export const defaultStorageForm: BackupStorageForm = {
provider: "local",
s3Endpoint: "",
s3Region: "us-east-1",
s3Bucket: "",
s3Prefix: "mikrotik",
s3AccessKeyId: "",
s3SecretAccessKey: "",
s3ForcePathStyle: true,
keepLocalCopy: true,
showPassword: false,
}
function storageStatus(saved: BackupStorageSettingsDto | null, form: BackupStorageForm) {
if (form.provider === "local") {
return { label: "Локально", variant: "secondary" as const }
}
if (saved?.lastTestError) {
return { label: "Ошибка", variant: "destructive-light" as const }
}
if (saved?.lastTestAt && !saved.lastTestError) {
return { label: "Connected", variant: "success-light" as const }
}
if (saved?.secretConfigured && saved.s3Bucket) {
return { label: "Не проверено", variant: "warning-light" as const }
}
return { label: "Не настроено", variant: "secondary" as const }
}
export function BackupsSettings({
schedule,
onScheduleChange,
storage,
onStorageChange,
savedStorage,
servers,
selectedServers,
onToggleServer,
onSelectAll,
onClearServers,
onSave,
onTest,
onSync,
saveBusy,
testBusy,
syncBusy,
}: {
schedule: BackupScheduleForm
onScheduleChange: <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => void
storage: BackupStorageForm
onStorageChange: <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => void
savedStorage: BackupStorageSettingsDto | null
servers: Server[]
selectedServers: Set<string>
onToggleServer: (id: string) => void
onSelectAll: () => void
onClearServers: () => void
onSave: () => void
onTest: () => void
onSync: () => void
saveBusy: boolean
testBusy: boolean
syncBusy: boolean
}) {
const status = storageStatus(savedStorage, storage)
const secretPlaceholder = savedStorage?.secretConfigured ? "•••••••• (сохранён)" : "••••••••"
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<OpsPanel title="Расписание" description="Автоматический съём конфигурации" contentClassName="px-5 py-5 flex flex-col gap-5">
<div className="flex items-center justify-between gap-3">
<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) => onScheduleChange("enabled", v)} />
</div>
<div className={cn("flex flex-col gap-4", !schedule.enabled && "opacity-40 pointer-events-none")}>
<FormField label="Частота">
<SegmentedControl
value={schedule.frequency}
onChange={(v) => onScheduleChange("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={d}
type="button"
onClick={() => onScheduleChange("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="128">
<Input
type="number"
min={1}
max={28}
className="font-mono w-24"
value={schedule.monthDay}
onChange={(e) => onScheduleChange("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))}
/>
</FormField>
)}
<FormField label="Время запуска">
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={23}
className="font-mono w-20 text-center"
value={String(schedule.hour).padStart(2, "0")}
onChange={(e) => onScheduleChange("hour", Math.min(23, Math.max(0, Number(e.target.value))))}
/>
<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={() => onScheduleChange("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>
<FormField label="Хранить бэкапов" hint="На каждый сервер">
<Input
type="number"
min={1}
max={90}
className="font-mono w-24"
value={schedule.keepCount}
onChange={(e) => onScheduleChange("keepCount", Math.max(1, Number(e.target.value)))}
/>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-3">
<div>
<p className="text-sm font-medium">Формат файла</p>
<p className="text-xs text-muted-foreground mt-0.5">Снимается текстовый экспорт RouterOS</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" size="sm">.rsc</Badge>
<Badge variant="warning-light" size="sm">.backup скоро</Badge>
</div>
</div>
</div>
</OpsPanel>
<OpsPanel
title="Хранилище"
description="Локальный диск приложения или S3-compatible бакет"
headerRight={
<Badge variant={status.variant} size="sm" radius="full">
{status.label}
</Badge>
}
contentClassName="px-5 py-5 flex flex-col gap-5"
>
<FormField label="Тип хранилища">
<SegmentedControl
value={storage.provider}
onChange={(v) => onStorageChange("provider", v)}
options={[
{ value: "local", label: "Локально" },
{ value: "s3", label: "S3" },
]}
/>
</FormField>
{storage.provider === "local" ? (
<p className="text-xs text-muted-foreground">
Файлы пишутся в каталог приложения <span className="font-mono text-foreground">storage/backups</span>.
</p>
) : (
<div className="flex flex-col gap-4">
<FormField label="Endpoint" hint="Пусто для AWS. Для R2/MinIO/Selectel — полный URL">
<Input
className="font-mono"
placeholder="https://s3.amazonaws.com"
value={storage.s3Endpoint}
onChange={(e) => onStorageChange("s3Endpoint", e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Region">
<Input
className="font-mono"
placeholder="us-east-1"
value={storage.s3Region}
onChange={(e) => onStorageChange("s3Region", e.target.value)}
/>
</FormField>
<FormField label="Bucket" required>
<Input
className="font-mono"
placeholder="mikrotik-backups"
value={storage.s3Bucket}
onChange={(e) => onStorageChange("s3Bucket", e.target.value)}
/>
</FormField>
</div>
<FormField label="Prefix">
<Input
className="font-mono"
placeholder="mikrotik"
value={storage.s3Prefix}
onChange={(e) => onStorageChange("s3Prefix", e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Access key" required>
<Input
className="font-mono"
autoComplete="off"
value={storage.s3AccessKeyId}
onChange={(e) => onStorageChange("s3AccessKeyId", e.target.value)}
/>
</FormField>
<FormField label="Secret key">
<div className="relative">
<Input
type={storage.showPassword ? "text" : "password"}
className="font-mono pr-14"
autoComplete="new-password"
placeholder={secretPlaceholder}
value={storage.s3SecretAccessKey}
onChange={(e) => onStorageChange("s3SecretAccessKey", e.target.value)}
/>
<button
type="button"
onClick={() => onStorageChange("showPassword", !storage.showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-xs text-muted-foreground hover:text-foreground"
>
{storage.showPassword ? "скрыть" : "показ"}
</button>
</div>
</FormField>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Path-style</p>
<p className="text-xs text-muted-foreground mt-0.5">Нужен для MinIO и части совместимых API</p>
</div>
<FormToggle checked={storage.s3ForcePathStyle} onChange={(v) => onStorageChange("s3ForcePathStyle", v)} />
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Оставлять локальную копию</p>
<p className="text-xs text-muted-foreground mt-0.5">После успешной загрузки в S3</p>
</div>
<FormToggle checked={storage.keepLocalCopy} onChange={(v) => onStorageChange("keepLocalCopy", v)} />
</div>
{savedStorage?.lastTestError ? (
<p className="text-xs text-destructive">{savedStorage.lastTestError}</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onTest} disabled={testBusy}>
{testBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
Проверить
</Button>
<Button type="button" variant="outline" size="sm" onClick={onSync} disabled={syncBusy}>
{syncBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
Синхронизировать из бакета
</Button>
</div>
</div>
)}
</OpsPanel>
<OpsPanel
className="lg:col-span-2"
title="Серверы для бэкапа"
headerRight={
<div className="flex items-center gap-2 shrink-0">
<button type="button" onClick={onSelectAll} className="text-xs text-primary hover:underline">
Выбрать все
</button>
<span className="text-border">·</span>
<button
type="button"
onClick={onClearServers}
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">
{servers.map((s) => {
const checked = selectedServers.has(s.id)
return (
<button
key={s.id}
type="button"
onClick={() => onToggleServer(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" aria-hidden="true">
<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>
</OpsPanel>
<div className="lg:col-span-2 flex items-center gap-3">
<Button type="button" onClick={onSave} className="gap-2" disabled={saveBusy}>
{saveBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
Сохранить настройки
</Button>
</div>
</div>
)
}
+79 -23
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo } from "react"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
@@ -8,8 +8,9 @@ import {
useReactTable,
} from "@tanstack/react-table"
import type { Backup } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
@@ -18,13 +19,28 @@ import {
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { DownloadIcon, HardDriveIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
import {
CloudIcon,
DownloadIcon,
HardDriveIcon,
RefreshCwIcon,
Trash2Icon,
} from "lucide-react"
interface BackupsDataGridProps {
backups: Backup[]
onDownload: (id: string, filename: string) => void
onRestore: (backup: Backup) => void
onDelete: (id: string) => void
onDelete: (backup: Backup) => void
emptyAction?: ReactNode
emptyTitle?: string
emptyDescription?: string
}
function storageBadge(storage: Backup["storage"]) {
if (storage === "s3") return { label: "S3", variant: "info-light" as const }
if (storage === "both") return { label: "Локально + S3", variant: "success-light" as const }
return { label: "Локально", variant: "secondary" as const }
}
function BackupsDataGrid({
@@ -32,6 +48,9 @@ function BackupsDataGrid({
onDownload,
onRestore,
onDelete,
emptyAction,
emptyTitle = "Нет бэкапов",
emptyDescription = "Создайте первый бэкап вручную или настройте расписание",
}: BackupsDataGridProps) {
const columns = useMemo<ColumnDef<Backup>[]>(
() => [
@@ -39,9 +58,27 @@ function BackupsDataGrid({
id: "filename",
accessorKey: "filename",
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
),
cell: ({ row }) => {
const b = row.original
const inS3 = b.storage === "s3" || b.storage === "both"
return (
<div className="flex items-center gap-3 min-w-0">
<IconTile
variant="elevated"
className={inS3 ? "size-10.5 shrink-0 text-info" : "size-10.5 shrink-0 text-muted-foreground"}
aria-hidden="true"
>
{inS3 ? <CloudIcon /> : <HardDriveIcon />}
</IconTile>
<div className="min-w-0">
<span className="font-mono text-xs font-medium block truncate">{b.filename}</span>
{b.uploadError ? (
<span className="text-[11px] text-destructive truncate block">{b.uploadError}</span>
) : null}
</div>
</div>
)
},
meta: {
headerTitle: "Файл",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
@@ -78,23 +115,35 @@ function BackupsDataGrid({
id: "kind",
accessorKey: "kind",
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
cell: ({ row }) => (
<Badge
variant={row.original.kind === "manual" ? "info-light" : "secondary"}
size="sm"
radius="full"
>
{row.original.kind === "auto" ? "авто" : "вручную"}
</Badge>
),
meta: {
headerTitle: "Тип",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "storage",
accessorKey: "storage",
header: ({ column }) => <DataGridSortHeader column={column} title="Хранилище" />,
cell: ({ row }) => {
const kind = row.original.kind
const badge = storageBadge(row.original.storage)
return (
<span
className={cn(
"text-xs px-2 py-0.5 rounded border font-medium",
kind === "manual"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-muted text-muted-foreground border-border",
)}
>
{kind === "auto" ? "авто" : "вручную"}
</span>
<Badge variant={badge.variant} size="sm" radius="full">
{badge.label}
</Badge>
)
},
meta: {
headerTitle: "Тип",
headerTitle: "Хранилище",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
@@ -135,29 +184,35 @@ function BackupsDataGrid({
return (
<div className="flex items-center gap-1 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
title="Скачать"
aria-label={`Скачать ${b.filename}`}
onClick={() => onDownload(b.id, b.filename)}
>
<DownloadIcon className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
title="Восстановить"
aria-label={`Восстановить ${b.filename}`}
onClick={() => onRestore(b)}
>
<RefreshCwIcon className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive hover:text-destructive"
title="Удалить"
onClick={() => onDelete(b.id)}
aria-label={`Удалить ${b.filename}`}
onClick={() => onDelete(b)}
>
<Trash2Icon className="size-3.5" />
</Button>
@@ -186,9 +241,10 @@ function BackupsDataGrid({
if (backups.length === 0) {
return (
<EmptyState
icon={<HardDriveIcon className="size-4" />}
title="Нет бэкапов"
description="Создайте первый бэкап вручную или настройте расписание"
icon={<HardDriveIcon className="size-5" />}
title={emptyTitle}
description={emptyDescription}
action={emptyAction}
className="border-0 py-10"
/>
)
+8 -1
View File
@@ -1,13 +1,14 @@
"use client"
import { ReactNode } from "react"
import { SearchIcon } from "lucide-react"
import { ListFilterIcon, SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { Button } from "@/components/ui/button"
import {
Filters,
type Filter,
@@ -77,6 +78,12 @@ function DataPageToolbar<T extends string = string>({
fields={filterFields}
onChange={onFiltersChange}
size="sm"
trigger={
<Button type="button" variant="outline" size="sm">
<ListFilterIcon className="size-3.5" />
Фильтры
</Button>
}
/>
)}
{onSearchChange != null && (