456 lines
19 KiB
TypeScript
456 lines
19 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 | string
|
||
minute: number | string
|
||
weekDay: number | string
|
||
monthDay: number | string
|
||
keepCount: number | string
|
||
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, dirty: boolean) {
|
||
if (dirty) return { label: "Не сохранено", variant: "warning-light" as const }
|
||
if (!saved) return { label: "Демонстрация", variant: "secondary" as const }
|
||
if (saved.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: "Проверено ранее", 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,
|
||
onSaveStorage,
|
||
scheduleKnown = true,
|
||
storageKnown = true,
|
||
storageDirty = false,
|
||
scheduleWritable = true,
|
||
storageWritable = true,
|
||
allEnabled = false,
|
||
onAllEnabledChange,
|
||
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
|
||
onSaveStorage: () => void
|
||
scheduleKnown?: boolean
|
||
storageKnown?: boolean
|
||
storageDirty?: boolean
|
||
scheduleWritable?: boolean
|
||
storageWritable?: boolean
|
||
allEnabled?: boolean
|
||
onAllEnabledChange: (value: boolean) => void
|
||
onSave: () => void
|
||
onTest: () => void
|
||
onSync: () => void
|
||
saveBusy: boolean
|
||
testBusy: boolean
|
||
syncBusy: boolean
|
||
}) {
|
||
const status = storageStatus(savedStorage, storageDirty)
|
||
const secretPlaceholder = savedStorage?.secretConfigured ? "•••••••• (сохранён)" : "••••••••"
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||
{!scheduleKnown ? <p role="status">Расписание пока неизвестно</p> : <fieldset disabled={!scheduleWritable || saveBusy || testBusy || syncBusy} className="min-w-0">
|
||
<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 ariaLabel="Автоматический бэкап" checked={schedule.enabled} onChange={(v) => onScheduleChange("enabled", v)} />
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<FormField label="Частота">
|
||
<SegmentedControl
|
||
className="w-full flex-wrap"
|
||
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 flex-wrap 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"
|
||
aria-label="День месяца"
|
||
value={schedule.monthDay}
|
||
onChange={(e) => onScheduleChange("monthDay", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
)}
|
||
|
||
<FormField label="Время запуска">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
max={23}
|
||
className="font-mono w-20 text-center"
|
||
aria-label="Час запуска"
|
||
value={schedule.hour}
|
||
onChange={(e) => onScheduleChange("hour", e.target.value)}
|
||
/>
|
||
<span className="text-muted-foreground font-mono text-lg">:</span>
|
||
<Input aria-label="Минута запуска" type="number" min={0} max={59} className="font-mono w-20 text-center" value={schedule.minute} onChange={e => onScheduleChange("minute", e.target.value)} />
|
||
<div className="flex flex-wrap 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={365}
|
||
className="font-mono w-24"
|
||
aria-label="Хранить копий"
|
||
value={schedule.keepCount}
|
||
onChange={(e) => onScheduleChange("keepCount", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
|
||
<FormField label="Формат файла" hint="Плановый binary .backup пока недоступен">
|
||
<select aria-label="Формат файла" className="h-9 rounded-md border px-3 bg-background" value={schedule.format} onChange={e => onScheduleChange("format", e.target.value as "rsc" | "backup")}>
|
||
<option value="rsc">Текстовый экспорт .rsc</option>
|
||
<option value="backup" disabled>Binary .backup — недоступен</option>
|
||
</select>
|
||
</FormField>
|
||
</div>
|
||
</OpsPanel>
|
||
</fieldset>}
|
||
|
||
{!storageKnown ? <p role="status">Хранилище пока неизвестно</p> : <fieldset disabled={!storageWritable || saveBusy || testBusy || syncBusy} className="min-w-0">
|
||
<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"
|
||
>
|
||
{savedStorage?.lastTestAt && <p className="text-xs text-muted-foreground">Последняя проверка: {new Date(savedStorage.lastTestAt).toLocaleString("ru-RU")}. Текущая доступность может измениться.</p>}
|
||
<FormField label="Тип хранилища">
|
||
<SegmentedControl
|
||
className="w-full flex-wrap"
|
||
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"
|
||
aria-label="Endpoint"
|
||
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"
|
||
aria-label="Region"
|
||
value={storage.s3Region}
|
||
onChange={(e) => onStorageChange("s3Region", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="Bucket" required>
|
||
<Input
|
||
className="font-mono"
|
||
placeholder="mikrotik-backups"
|
||
aria-label="Bucket"
|
||
value={storage.s3Bucket}
|
||
onChange={(e) => onStorageChange("s3Bucket", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
<FormField label="Prefix">
|
||
<Input
|
||
className="font-mono"
|
||
placeholder="mikrotik"
|
||
aria-label="Prefix"
|
||
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"
|
||
aria-label="Access key"
|
||
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}
|
||
aria-label="Secret key"
|
||
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 ariaLabel="Path-style" 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 ariaLabel="Оставлять локальную копию" 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>
|
||
</fieldset>}
|
||
|
||
<fieldset disabled={!scheduleKnown || !scheduleWritable || saveBusy || testBusy || syncBusy} className="min-w-0 lg:col-span-2">
|
||
<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="flex items-center gap-2 text-sm"><Checkbox aria-label="Все включённые серверы" checked={allEnabled} onCheckedChange={value => onAllEnabledChange(value === true)} /><span>Все включённые серверы, включая добавленные позже</span></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 (
|
||
<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
|
||
disabled={allEnabled}
|
||
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">
|
||
{allEnabled ? "Все включённые устройства, включая добавленные позже" : "Выбрано " + selectedServers.size + " из " + servers.length + " серверов"}
|
||
</p>
|
||
</OpsPanel>
|
||
</fieldset>
|
||
|
||
<div className="lg:col-span-2 flex flex-wrap items-center gap-3">
|
||
<Button type="button" onClick={onSave} className="gap-2" disabled={!scheduleKnown || !scheduleWritable || saveBusy || testBusy || syncBusy}>
|
||
{saveBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||
Сохранить расписание
|
||
</Button>
|
||
<Button type="button" onClick={onSaveStorage} disabled={!storageKnown || !storageWritable || saveBusy || testBusy || syncBusy}>Сохранить хранилище</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|