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
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 <cursoragent@cursor.com>
This commit is contained in:
1 parent
39c8ec4a02
commit
cff26813b9
21 files changed
+2429
-637
No files matched your search
@@ -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="1–28">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user