Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m4s
Docker images / frontend-image (push) Successful in 3m10s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m29s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Updated the BackupCreateSheet and BackupsSettings components to use checkboxes for selecting servers instead of buttons. This improves user interaction by allowing for easier selection and toggling of server states. The Checkbox component is now integrated for better accessibility and functionality.
420 lines
16 KiB
TypeScript
420 lines
16 KiB
TypeScript
"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 { Checkbox } from "@/components/ui/checkbox"
|
||
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 (
|
||
<label
|
||
key={s.id}
|
||
className={cn(
|
||
"flex cursor-pointer 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",
|
||
)}
|
||
>
|
||
<Checkbox
|
||
checked={checked}
|
||
onCheckedChange={() => onToggleServer(s.id)}
|
||
aria-label={`Выбрать ${s.name}`}
|
||
/>
|
||
<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>
|
||
</label>
|
||
)
|
||
})}
|
||
</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>
|
||
)
|
||
}
|