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]>
543 lines
19 KiB
TypeScript
543 lines
19 KiB
TypeScript
"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>
|
||
)
|
||
}
|