Files
DenozordecandCursor cff26813b9
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
feat(backups): добавить S3-хранилище и обновить экран бэкапов
Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами.
Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление.

Co-authored-by: Cursor <[email protected]>
2026-09-08 14:06:27 +07:00

543 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useCallback, useEffect, useState } from "react"
import { PageHeader } from "@/components/page-header"
import type { Backup, Server } from "@/lib/data"
import type { Filter } from "@/components/reui/filters"
import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
CloudIcon,
ClockIcon,
HardDriveIcon,
PlusIcon,
RefreshCwIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers"
import {
createBackupsAsync,
deleteBackup,
getBackupJob,
getBackupScheduleSettings,
getBackupStorageSettings,
listBackups,
putBackupScheduleSettings,
putBackupStorageSettings,
restoreBackup,
syncBackupsFromStorage,
testBackupStorage,
type BackupItem,
} from "@/shared/api/backups"
import { requestBlob } from "@/shared/api/http-client"
import { toast } from "sonner"
import { BackupsHistory } from "@/components/backups/backups-history"
import {
BackupsSettings,
WEEK_DAYS,
defaultSchedule,
defaultStorageForm,
type BackupScheduleForm,
type BackupStorageForm,
} from "@/components/backups/backups-settings"
import { BackupCreateSheet } from "@/components/backups/backup-create-sheet"
import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs"
type PageTab = "history" | "settings"
type KindFilter = "all" | "auto" | "manual"
export default function BackupsPage() {
const { backendUrl } = useDataSource()
const [tab, setTab] = useState<PageTab>("history")
const [backupList, setBackupList] = useState<Backup[]>([])
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
const [search, setSearch] = useState("")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false)
const [opBusy, setOpBusy] = useState(false)
const [opError, setOpError] = useState<string | null>(null)
const [backupJobId, setBackupJobId] = useState<string | null>(null)
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
const [testBusy, setTestBusy] = useState(false)
const [syncBusy, setSyncBusy] = useState(false)
const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule)
const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm)
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
const [manualOpen, setManualOpen] = useState(false)
const [manualStep, setManualStep] = useState(1)
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
const [manualNotes, setManualNotes] = useState("")
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null)
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) =>
setSchedule((s) => ({ ...s, [k]: v }))
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) =>
setStorage((s) => ({ ...s, [k]: v }))
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
const kb = Math.max(1, Math.round(b.sizeBytes / 1024))
return {
id: b.id,
server: b.serverName,
serverId: b.serverId == null ? null : String(b.serverId),
filename: b.filename,
size: `${kb} КБ`,
created: new Date(b.createdAt).toLocaleString("ru-RU"),
kind: b.kind,
notes: b.notes ?? "",
storage: b.storage ?? "local",
uploadError: b.uploadError ?? undefined,
}
}, [])
const loadLive = useCallback(async () => {
setLoading(true)
setOpError(null)
try {
const [serversRows, backupsRows, scheduleRow, storageRow] = await Promise.all([
listServers(backendUrl),
listBackups(backendUrl),
getBackupScheduleSettings(backendUrl),
getBackupStorageSettings(backendUrl),
])
const mappedServers = serversRows.map((s) => toFrontendServer(s))
setLiveServers(mappedServers)
setBackupList(backupsRows.map(mapApiBackupToUi))
setSchedule({
enabled: scheduleRow.enabled,
frequency: scheduleRow.frequency,
hour: scheduleRow.hour,
minute: scheduleRow.minute,
weekDay: scheduleRow.weekDay,
monthDay: scheduleRow.monthDay,
keepCount: scheduleRow.keepCount,
format: scheduleRow.format,
})
setSavedStorage(storageRow)
setScheduleLastError(scheduleRow.lastError ?? null)
setStorage((prev) => ({
...prev,
provider: storageRow.provider,
s3Endpoint: storageRow.s3Endpoint,
s3Region: storageRow.s3Region,
s3Bucket: storageRow.s3Bucket,
s3Prefix: storageRow.s3Prefix,
s3AccessKeyId: storageRow.s3AccessKeyId,
s3SecretAccessKey: "",
s3ForcePathStyle: storageRow.s3ForcePathStyle,
keepLocalCopy: storageRow.keepLocalCopy,
}))
const serverIds = scheduleRow.serverIds.length > 0
? scheduleRow.serverIds
: mappedServers.map((s) => s.id)
setSelectedServers(new Set(serverIds.filter((id) => mappedServers.some((s) => s.id === id))))
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
} finally {
setLoading(false)
}
}, [backendUrl, mapApiBackupToUi])
useEffect(() => {
void loadLive()
}, [loadLive])
async function handleSave() {
if (scheduleSaveBusy) return
setScheduleSaveBusy(true)
setOpError(null)
try {
await putBackupScheduleSettings(backendUrl, {
enabled: schedule.enabled,
frequency: schedule.frequency,
hour: schedule.hour,
minute: schedule.minute,
weekDay: schedule.weekDay,
monthDay: schedule.monthDay,
keepCount: schedule.keepCount,
format: "rsc",
serverIds: [...selectedServers],
})
const saved = await putBackupStorageSettings(backendUrl, {
provider: storage.provider,
s3Endpoint: storage.s3Endpoint,
s3Region: storage.s3Region,
s3Bucket: storage.s3Bucket,
s3Prefix: storage.s3Prefix,
s3AccessKeyId: storage.s3AccessKeyId,
s3SecretAccessKey: storage.s3SecretAccessKey || undefined,
s3ForcePathStyle: storage.s3ForcePathStyle,
keepLocalCopy: storage.keepLocalCopy,
})
setSavedStorage(saved)
setStorage((prev) => ({ ...prev, s3SecretAccessKey: "" }))
toast.success("Настройки сохранены")
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка сохранения настроек")
} finally {
setScheduleSaveBusy(false)
}
}
async function handleTestStorage() {
setTestBusy(true)
try {
await putBackupStorageSettings(backendUrl, {
provider: storage.provider,
s3Endpoint: storage.s3Endpoint,
s3Region: storage.s3Region,
s3Bucket: storage.s3Bucket,
s3Prefix: storage.s3Prefix,
s3AccessKeyId: storage.s3AccessKeyId,
s3SecretAccessKey: storage.s3SecretAccessKey || undefined,
s3ForcePathStyle: storage.s3ForcePathStyle,
keepLocalCopy: storage.keepLocalCopy,
})
const result = await testBackupStorage(backendUrl)
setSavedStorage(result)
toast.success("S3 доступен")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка проверки S3")
try {
setSavedStorage(await getBackupStorageSettings(backendUrl))
} catch {
/* ignore */
}
} finally {
setTestBusy(false)
}
}
async function handleSyncStorage() {
setSyncBusy(true)
try {
const result = await syncBackupsFromStorage(backendUrl)
toast.success(`Импортировано: ${result.imported}, пропущено: ${result.skipped}`)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка синхронизации")
} finally {
setSyncBusy(false)
}
}
async function handleManualBackup() {
if (manualServers.size === 0 || opBusy) return
setOpError(null)
try {
const res = await createBackupsAsync(backendUrl, {
serverIds: [...manualServers],
notes: manualNotes || undefined,
})
setBackupJobId(res.jobId)
setManualOpen(false)
setManualServers(new Set())
setManualNotes("")
setTab("history")
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка создания бэкапа")
}
}
useEffect(() => {
if (!backupJobId) return
toast.info("Бэкап выполняется в фоне...")
let cancelled = false
const timer = setInterval(() => {
void (async () => {
try {
const job = await getBackupJob(backendUrl, backupJobId)
if (cancelled) return
if (job.status === "done") {
clearInterval(timer)
setBackupJobId(null)
if (job.failures.length > 0) {
setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`)
} else {
toast.success("Бэкап успешно завершён")
}
await loadLive()
} else if (job.status === "failed") {
clearInterval(timer)
setBackupJobId(null)
setOpError(job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ") || "Ошибка фоновой задачи бэкапа")
await loadLive()
}
} catch (e) {
clearInterval(timer)
setBackupJobId(null)
setOpError(e instanceof Error ? e.message : "Ошибка опроса задачи бэкапа")
}
})()
}, 1200)
return () => {
cancelled = true
clearInterval(timer)
}
}, [backendUrl, backupJobId, loadLive])
useEffect(() => {
if (opError) toast.error(opError)
}, [opError])
async function handleDelete() {
if (!deleteTarget) return
setOpBusy(true)
setOpError(null)
try {
await deleteBackup(backendUrl, deleteTarget.id)
setDeleteTarget(null)
await loadLive()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка удаления бэкапа")
} finally {
setOpBusy(false)
}
}
async function handleRestore() {
if (!restoreTarget) return
setOpBusy(true)
setOpError(null)
try {
await restoreBackup(backendUrl, restoreTarget.id)
toast.success(`Конфигурация восстановлена на ${restoreTarget.server}`)
setRestoreTarget(null)
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка восстановления")
} finally {
setOpBusy(false)
}
}
async function handleDownload(id: string, fallbackFilename: string) {
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = fallbackFilename
a.click()
URL.revokeObjectURL(url)
}
const autoCount = backupList.filter((b) => b.kind === "auto").length
const manualCount = backupList.filter((b) => b.kind === "manual").length
const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length
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}`
})()
const destinationLabel = storage.provider === "s3"
? (storage.keepLocalCopy ? "локально и в S3" : "в S3")
: "локально (storage/backups)"
const statusDetail = [
backupJobId ? null : `${destinationLabel} · ${selectedServers.size} из ${liveServers.length} серверов`,
scheduleLastError,
savedStorage?.lastTestError,
].filter(Boolean).join(" · ")
const alertVariant = backupJobId
? "info"
: opError || scheduleLastError || savedStorage?.lastTestError
? "warning"
: "default"
function openCreate(all: boolean) {
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
setManualNotes("")
setManualStep(1)
setManualOpen(true)
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
actions={
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openCreate(true)}
disabled={loading}
>
<RefreshCwIcon className="size-4" />
Снять со всех
</Button>
<Button
type="button"
size="sm"
onClick={() => openCreate(false)}
disabled={loading}
>
<PlusIcon className="size-4" />
Новый бэкап
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
<KpiStatGrid
aria-label="Сводка бэкапов"
items={[
{
id: "all",
label: "Всего бэкапов",
value: backupList.length,
icon: <HardDriveIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "auto",
label: "Авто",
value: autoCount,
icon: <ClockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "manual",
label: "Вручную",
value: manualCount,
icon: <PlusIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "s3",
label: "В S3",
value: s3Count,
icon: <CloudIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<Alert variant={alertVariant}>
<ClockIcon />
<AlertTitle>
{backupJobId ? "Задача выполняется" : schedSummary}
</AlertTitle>
<AlertDescription>
{backupJobId
? "Снимок конфигурации снимается в фоне. Список обновится после завершения."
: statusDetail}
</AlertDescription>
<AlertAction>
<Button type="button" variant="ghost" size="xs" onClick={() => setTab("settings")}>
Настройки
</Button>
</AlertAction>
</Alert>
<Tabs
value={tab}
onValueChange={(v) => setTab(String(v) as PageTab)}
className="gap-5"
>
<TabsList variant="line" className="w-fit">
<TabsTrigger value="history">История бэкапов</TabsTrigger>
<TabsTrigger value="settings">Настройки</TabsTrigger>
</TabsList>
<TabsContent value="history" className="flex flex-col gap-5">
<BackupsHistory
backups={backupList}
kindFilter={kindFilter}
onKindFilterChange={setKindFilter}
search={search}
onSearchChange={setSearch}
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
onDownload={handleDownload}
onRestore={setRestoreTarget}
onDelete={setDeleteTarget}
onCreate={() => openCreate(false)}
/>
</TabsContent>
<TabsContent value="settings">
<BackupsSettings
schedule={schedule}
onScheduleChange={setSched}
storage={storage}
onStorageChange={setStore}
savedStorage={savedStorage}
servers={liveServers}
selectedServers={selectedServers}
onToggleServer={(id) => {
setSelectedServers((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}}
onSelectAll={() => setSelectedServers(new Set(liveServers.map((s) => s.id)))}
onClearServers={() => setSelectedServers(new Set())}
onSave={handleSave}
onTest={handleTestStorage}
onSync={handleSyncStorage}
saveBusy={scheduleSaveBusy}
testBusy={testBusy}
syncBusy={syncBusy}
/>
</TabsContent>
</Tabs>
</div>
</div>
<BackupCreateSheet
open={manualOpen}
onOpenChange={setManualOpen}
step={manualStep}
onStepChange={setManualStep}
servers={liveServers}
selected={manualServers}
onToggle={(id) => {
setManualServers((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}}
onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
onClear={() => setManualServers(new Set())}
notes={manualNotes}
onNotesChange={setManualNotes}
destinationLabel={destinationLabel}
busy={backupJobId !== null}
onSubmit={handleManualBackup}
/>
<BackupDeleteDialog
backup={deleteTarget}
busy={opBusy}
onConfirm={() => void handleDelete()}
onCancel={() => setDeleteTarget(null)}
/>
<BackupRestoreDialog
backup={restoreTarget}
busy={opBusy}
onConfirm={() => void handleRestore()}
onCancel={() => setRestoreTarget(null)}
/>
</div>
)
}