"use client" import { Suspense, useCallback, useEffect, useRef, useState } from "react" import { Frame, FramePanel } from "@/components/reui/frame" import { PageHeader } from "@/components/page-header" import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data" import { useBackupsUrlState } from "@/hooks/use-backups-url-state" import { backupStorageSettingsDtoSchema, type BackupScheduleSettingsDto, 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 { serverListSchema } from "@mmapp/contracts/servers" import { can } from "@/lib/auth" import { userApiError } from "@/shared/api/users" import { toFrontendServer } from "@/entities/server/model/mappers" import { deleteBackup, downloadBackup, getBackupScheduleSettings, getBackupStorageSettings, listBackups, putBackupScheduleSettings, putBackupStorageSettings, restoreBackup, syncBackupsFromStorage, testBackupStorage, type BackupItem, } from "@/shared/api/backups" import { ApiClientError, requestJson } from "@/shared/api/http-client" import { toast } from "sonner" import { useBackupRestoreGuard } from "@/lib/use-backup-restore-guard" import { restoreWasNotApplied } from "@/lib/backup-restore-checkpoint" import { Checkbox } from "@/components/ui/checkbox" import { useBackupJob } from "@/lib/use-backup-job" import { backupJobOutcome, backupJobResultError } from "@/lib/backup-job-poll" import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings" import { BackupsHistory } from "@/components/backups/backups-history" import { backupFileMissing } from "@/lib/backup-file-state" 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 Resource = "backups" | "servers" | "schedule" | "storage" type ResourceState = { known: boolean; error: string | null; receivedAt: string | null } const unknownResource = (): ResourceState => ({ known: false, error: null, receivedAt: null }) export default function BackupsPage() { return Загрузка контекста резервных копий…

}>
} function BackupsSource() { const { mode, backendUrl, prefsHydrated } = useDataSource() if (!prefsHydrated) return

Загрузка настроек источника…

return } function BackupsContent() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const canRead = can("mm:backups:read") const canWrite = isLive && can("mm:backups:write") const canReadServers = can("mm:servers:read") const [resources, setResources] = useState>(() => ({ backups: unknownResource(), servers: unknownResource(), schedule: unknownResource(), storage: unknownResource() })) const dialogTrigger = useRef<{ element: HTMLElement; rowId: string | null; label: string | null } | null>(null) const refreshTrigger = useRef(null) const captureDialogFocus = () => { const element = document.activeElement dialogTrigger.current = element instanceof HTMLElement ? { element, rowId: element.closest("[data-row-id]")?.getAttribute("data-row-id") ?? null, label: element.getAttribute("aria-label") } : null } const returnDialogFocus = () => { const trigger = dialogTrigger.current if (trigger?.element.isConnected) return trigger.element // Cell renderers can remount when operation state changes; resolve the same row/action. if (trigger?.rowId && trigger.label) { const replacement = document.querySelector(`[data-row-id="${CSS.escape(trigger.rowId)}"] button[aria-label="${CSS.escape(trigger.label)}"]`) if (replacement) return replacement } return refreshTrigger.current } const reader = useRef(null) const revision = useRef(0) const alive = useRef(true) const scheduleDirty = useRef(false) const storageDirty = useRef(false) const operationLock = useRef(false) const operationController = useRef(null) const downloadControllers = useRef(new Map()) const settingsLock = useRef(false) const settingsController = useRef(null) const known = (resource: Resource) => !isLive || resources[resource].known const current = (resource: Resource) => known(resource) && !resources[resource].error const { tab, setTab, kindFilter, setKindFilter, search, setSearch, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, reset: resetContext } = useBackupsUrlState() const [backupList, setBackupList] = useState(isLive ? [] : mockBackups) const [liveServers, setLiveServers] = useState(isLive ? [] : mockServers) const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage") const [loading, setLoading] = useState(false) const restoreGuard = useBackupRestoreGuard(backendUrl, isLive) const [restoreReviewed, setRestoreReviewed] = useState(false) const [opBusy, setOpBusy] = useState(false) const [opError, setOpError] = useState(null) const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false) const [testBusy, setTestBusy] = useState(false) const [syncBusy, setSyncBusy] = useState(false) const [schedule, setSchedule] = useState(defaultSchedule) const [storage, setStorage] = useState(defaultStorageForm) const [storageIsDirty, setStorageIsDirty] = useState(false) const [savedSchedule, setSavedSchedule] = useState(null) const [savedStorage, setSavedStorage] = useState(null) const [scheduleLastError, setScheduleLastError] = useState(null) const [allEnabled, setAllEnabled] = useState(true) const [selectedServers, setSelectedServers] = useState>(new Set()) const [manualOpen, setManualOpen] = useState(false) const [manualStep, setManualStep] = useState(1) const [manualServers, setManualServers] = useState>(new Set()) const [manualNotes, setManualNotes] = useState("") const [deleteTarget, setDeleteTarget] = useState(null) const [restoreTarget, setRestoreTarget] = useState(null) const setSched = (k: K, v: BackupScheduleForm[K]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) } const setStore = (k: K, v: BackupStorageForm[K]) => { if (k !== "showPassword") { storageDirty.current = true; setStorageIsDirty(true) } setStorage(s => ({ ...s, [k]: v })) } const mapApiBackupToUi = useCallback((b: BackupItem): Backup => { const kb = Math.round(b.sizeBytes / 1024) return { id: b.id, server: b.serverName, serverId: b.serverId == null ? null : String(b.serverId), filename: b.filename, size: b.sizeBytes < 1024 ? `${b.sizeBytes} Б` : `${kb} КБ`, sizeBytes: b.sizeBytes, created: new Date(b.createdAt).toLocaleString("ru-RU"), kind: b.kind, notes: b.notes ?? "", storage: b.storage, uploadError: b.uploadError ?? undefined, localFileStatus: b.localFileStatus, } }, []) const loadLive = useCallback(async () => { if (!isLive || !canRead || !alive.current) return reader.current?.abort() const abort = new AbortController(), version = ++revision.current reader.current = abort const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]) const active = () => alive.current && !abort.signal.aborted && revision.current === version const success = (resource: Resource) => { if (active()) setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } })) } const failure = (resource: Resource, error: unknown) => { if (active()) setResources(prev => ({ ...prev, [resource]: { ...prev[resource], error: userApiError(error) } })) } setLoading(true) await Promise.allSettled([ listBackups(backendUrl, { signal }).then(rows => { if (!active()) return; setBackupList(rows.map(mapApiBackupToUi)); success("backups") }).catch(error => failure("backups", error)), canReadServers ? requestJson(backendUrl, "/api/servers", { signal }).then(payload => { const rows = serverListSchema.parse(payload) if (!active()) return setLiveServers(rows.map(row => ({ ...toFrontendServer(row), status: row.status ?? "unknown" }))); success("servers") }).catch(error => failure("servers", error)) : Promise.resolve(), getBackupScheduleSettings(backendUrl, { signal }).then(row => { if (!active()) return if (!scheduleDirty.current) { setSchedule(row); setSelectedServers(new Set(row.serverIds)); setAllEnabled(row.serverIds.length === 0) } setSavedSchedule(row); setScheduleLastError(row.lastError ?? null); success("schedule") }).catch(error => failure("schedule", error)), getBackupStorageSettings(backendUrl, { signal }).then(row => { if (!active()) return setSavedStorage(row) if (!storageDirty.current) setStorage(prev => ({ ...prev, ...row, s3SecretAccessKey: "" })) success("storage") }).catch(error => failure("storage", error)), ]) if (active()) setLoading(false) }, [backendUrl, isLive, canRead, canReadServers, mapApiBackupToUi]) useEffect(() => { alive.current = true const downloads = downloadControllers.current queueMicrotask(() => { if (alive.current) void loadLive() }) return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort(); operationController.current?.abort(); downloads.forEach(controller => controller.abort()) } }, [loadLive]) const job = useBackupJob(backendUrl, isLive, canRead, baseCanCreate && !opBusy && !scheduleSaveBusy, loadLive) const checkpoint = job.checkpoint const jobTerminal = !!checkpoint?.job && ["done", "failed"].includes(checkpoint.job.status) const backupJobId = checkpoint && !jobTerminal ? checkpoint.request.requestId : null const persistedJobError = checkpoint?.job && jobTerminal ? backupJobResultError(checkpoint.job) : null const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError function startSettingsMutation() { if (!alive.current || settingsLock.current || operationLock.current || backupJobId) return null settingsLock.current = true const abort = new AbortController() settingsController.current = abort reader.current?.abort(); revision.current += 1; setLoading(false) setScheduleSaveBusy(true); setOpError(null) return abort } function finishSettingsMutation(abort: AbortController) { if (settingsController.current !== abort) return settingsLock.current = false; settingsController.current = null if (alive.current) { setScheduleSaveBusy(false); setTestBusy(false); setSyncBusy(false) } } function settingsConfirmed(resource: Resource) { setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } })) } async function handleSave() { if (!canWrite || !current("schedule") || !current("servers") || !canReadServers) return const abort = startSettingsMutation(); if (!abort) return try { const payload = backupSchedulePayload(schedule, [...selectedServers], allEnabled) if (payload.serverIds?.some(id => !liveServers.some(server => server.id === id))) throw new Error("Выбранный сервер отсутствует; обновите или измените выбор") const result = await putBackupScheduleSettings(backendUrl, payload, { signal: abort.signal }) if (!alive.current || abort.signal.aborted) return setSavedSchedule(result); setSchedule(result); setScheduleLastError(result.lastError ?? null); scheduleDirty.current = false setSelectedServers(new Set(result.serverIds)); setAllEnabled(result.serverIds.length === 0); settingsConfirmed("schedule") toast.success("Расписание сохранено") } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } finally { finishSettingsMutation(abort) } } async function handleSaveStorage() { if (!canWrite || !current("storage")) return const abort = startSettingsMutation(); if (!abort) return try { const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage, savedStorage?.secretConfigured), { signal: abort.signal }) if (!alive.current || abort.signal.aborted) return setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; setStorageIsDirty(false); settingsConfirmed("storage") toast.success("Хранилище сохранено") } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } finally { finishSettingsMutation(abort) } } async function handleTestStorage() { if (!canWrite || !current("storage")) return if (storageDirty.current) { setOpError("Сначала сохраните хранилище; проверка использует подтверждённые настройки"); return } if (savedStorage?.provider !== "s3") { setOpError("Проверка доступна для сохранённого хранилища S3"); return } const abort = startSettingsMutation(); if (!abort) return setTestBusy(true) try { const result = await testBackupStorage(backendUrl, { signal: abort.signal }) if (!alive.current || abort.signal.aborted) return setSavedStorage(result); settingsConfirmed("storage") if (result.lastTestError || !result.lastTestAt || result.provider !== "s3") throw new Error(result.lastTestError || "Проверка S3 не подтверждена") toast.success("Доступ к S3 подтверждён") } catch (error) { if (alive.current && !abort.signal.aborted) { if (error instanceof ApiClientError && error.payload && typeof error.payload === "object" && "settings" in error.payload) { const parsed = backupStorageSettingsDtoSchema.safeParse(error.payload.settings) if (parsed.success) setSavedStorage(parsed.data) } setOpError(userApiError(error)) } } finally { finishSettingsMutation(abort) } } async function handleSyncStorage() { if (!canWrite || !current("storage")) return if (storageDirty.current) { setOpError("Сначала сохраните хранилище; синхронизация использует подтверждённые настройки"); return } if (savedStorage?.provider !== "s3") return const abort = startSettingsMutation(); if (!abort) return setSyncBusy(true) try { const result = await syncBackupsFromStorage(backendUrl, { signal: abort.signal }) if (!alive.current || abort.signal.aborted) return toast.success("Импортировано: " + result.imported + ", пропущено: " + result.skipped) await loadLive() } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } finally { finishSettingsMutation(abort) } } async function handleManualBackup() { if (checkpoint) { await job.run(); return } if (!canCreate || !manualServers.size || settingsLock.current || opBusy) return if ([...manualServers].some(id => !liveServers.some(server => server.id === id))) { setOpError("Выбранный сервер отсутствует; обновите выбор"); return } await job.run({ serverIds: [...manualServers], notes: manualNotes || undefined }) } useEffect(() => { if (opError) toast.error(opError) }, [opError]) function startOperation() { if (!canWrite || !alive.current || operationLock.current || settingsLock.current || backupJobId) return null operationLock.current = true const abort = new AbortController(); operationController.current = abort reader.current?.abort(); revision.current += 1; setLoading(false) setOpBusy(true); setOpError(null) return abort } function finishOperation(abort: AbortController) { if (operationController.current !== abort) return operationLock.current = false; operationController.current = null if (alive.current) setOpBusy(false) } async function handleDelete() { if (!deleteTarget) return const target = deleteTarget, abort = startOperation(); if (!abort) return try { await deleteBackup(backendUrl, target.id, { signal: abort.signal }, backupFileMissing(target)) if (!alive.current || abort.signal.aborted) return setBackupList(previous => previous.filter(backup => backup.id !== target.id)); setDeleteTarget(null) toast.success("API подтвердил удаление резервной копии") await loadLive() } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } finally { finishOperation(abort) } } async function handleRestore() { if (!restoreTarget || !restoreTarget.serverId || !restoreGuard.canStart) return if (backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget)) { setOpError("Локальный файл отсутствует. Создайте новую резервную копию.") return } const target = restoreTarget, abort = startOperation(); if (!abort) return let sent = false try { if (!restoreGuard.begin({ backupId: target.id, serverId: Number(target.serverId), filename: target.filename, serverName: target.server })) return sent = true const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }, Number(target.serverId)) if (!alive.current || abort.signal.aborted) return if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу") toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName) restoreGuard.clear(); setRestoreTarget(null) } catch (error) { if (alive.current && !abort.signal.aborted) { if (sent && restoreWasNotApplied(error)) restoreGuard.clear() setOpError(userApiError(error) + (sent && !restoreWasNotApplied(error) ? ". Результат импорта неизвестен; проверьте роутер перед повтором" : "")) } } finally { finishOperation(abort) } } async function handleDownload(id: string, filename: string) { if (!isLive || !canRead || !alive.current || downloadControllers.current.has(id)) return const abort = new AbortController(); downloadControllers.current.set(id, abort) try { const blob = await downloadBackup(backendUrl, id, { signal: abort.signal }) if (!alive.current || abort.signal.aborted) return const url = URL.createObjectURL(blob) try { const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click() } finally { URL.revokeObjectURL(url) } } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) } finally { if (downloadControllers.current.get(id) === abort) downloadControllers.current.delete(id) } } 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 = (() => { const confirmed = isLive ? savedSchedule : defaultSchedule if (!confirmed) return "Расписание пока неизвестно" if (!confirmed.enabled) return "Расписание отключено" const time = String(confirmed.hour).padStart(2, "0") + ":" + String(confirmed.minute).padStart(2, "0") if (confirmed.frequency === "daily") return "Каждый день в " + time if (confirmed.frequency === "weekly") return "Каждую неделю (" + WEEK_DAYS[Number(confirmed.weekDay)] + ") в " + time return confirmed.monthDay + "-го числа каждого месяца в " + time })() const confirmedStorage = isLive ? savedStorage : defaultStorageForm const destinationLabel = !confirmedStorage ? "Хранилище пока неизвестно" : confirmedStorage.provider === "s3" ? (confirmedStorage.keepLocalCopy ? "локально и в S3" : "в S3") : "локально (storage/backups)" const statusDetail = [ backupJobId ? null : `${destinationLabel} · ${known("servers") ? liveServers.length + " серверов в каталоге" : "Серверы пока неизвестны"}`, scheduleLastError, savedStorage?.lastTestError, ].filter(Boolean).join(" · ") const alertVariant = backupJobId ? "info" : opError || scheduleLastError || savedStorage?.lastTestError ? "warning" : "default" function openCreate(all: boolean) { if (checkpoint) { setManualStep(3); setManualOpen(true); return } if (!canCreate) return setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set()) setManualNotes("") setManualStep(1) setManualOpen(true) } return (
} />
{urlError && Не удалось прочитать ссылку{urlError}} {!isLive &&

Демонстрационные данные. Операции и сохранение недоступны.

} {!canRead &&

Нет права mm:backups:read

} {isLive && !canReadServers &&

Каталог устройств недоступен без mm:servers:read.

} {isLive && canRead &&
{loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}
} {isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource => {resources[resource].known ? "Последние данные сохранены" : "Данные пока неизвестны"} {({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error} )} {isLive && (restoreGuard.checkpoint || restoreGuard.error) && {opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"} {restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")} } {opError && Операция не подтверждена{opError}} {(job.error || job.storageError || persistedJobError) && {jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}{job.error || job.storageError || persistedJobError}}

Резервные копии

История файлов, расписание и подтверждения операций

, iconClassName: "text-muted-foreground", }, { id: "auto", label: "Авто", value: known("backups") ? autoCount : "—", icon: , iconClassName: "text-info", }, { id: "manual", label: "Вручную", value: known("backups") ? manualCount : "—", icon: , iconClassName: "text-muted-foreground", }, { id: "s3", label: "В S3", value: known("backups") ? s3Count : "—", icon: , iconClassName: "text-primary", }, ]} /> {backupJobId ? (checkpoint?.accepted ? "Задача наблюдается" : "Результат запроса пока неизвестен") : schedSummary} {backupJobId ? "Закрытие формы не отменяет задачу. Проверка продолжает чтение того же ID." : statusDetail} setTab(String(v) as PageTab)} className="gap-5" > История бэкапов Настройки {urlError ? null : !canRead ?

История недоступна без права чтения

: !known("backups") ?

{resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}

: { captureDialogFocus(); setOpError(null); setRestoreTarget(backup) }} onDelete={backup => { captureDialogFocus(); setOpError(null); setDeleteTarget(backup) }} onCreate={() => openCreate(false)} canCreate={canCreate} canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId} canRestore={restoreGuard.canStart} canDownload={isLive && canRead} />}
{ scheduleDirty.current = true setSelectedServers((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) }} onSelectAll={() => { scheduleDirty.current = true; setAllEnabled(false); setSelectedServers(new Set(liveServers.map(s => s.id))) }} onClearServers={() => { scheduleDirty.current = true; setSelectedServers(new Set()) }} onSave={handleSave} onSaveStorage={handleSaveStorage} scheduleKnown={known("schedule")} storageKnown={known("storage")} storageDirty={storageIsDirty} scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers") && !backupJobId} storageWritable={canWrite && current("storage") && !backupJobId} allEnabled={allEnabled} onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }} onTest={handleTestStorage} onSync={handleSyncStorage} saveBusy={scheduleSaveBusy} testBusy={testBusy} syncBusy={syncBusy} />
{ 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={checkpoint?.request.notes ?? manualNotes} onNotesChange={setManualNotes} destinationLabel={destinationLabel} busy={job.busy} locked={!!checkpoint} terminal={jobTerminal} canObserve={canRead} jobId={checkpoint?.request.requestId} jobStatus={checkpoint?.job ? ({ queued: "В очереди", running: "Выполняется", done: checkpoint?.job && backupJobOutcome(checkpoint.job).fullSuccess ? "Завершена" : "Завершена с ограничениями", failed: "Прервана" })[checkpoint.job.status] + ": " + checkpoint.job.completed + "/" + checkpoint.job.total : checkpoint?.accepted ? "Запрос принят, результат ещё неизвестен" : "Результат запроса ещё неизвестен"} error={job.error || job.storageError || persistedJobError} result={checkpoint?.job && jobTerminal ? "Создано " + backupJobOutcome(checkpoint.job).created + " из " + checkpoint.job.total : undefined} onReset={() => { if (job.reset()) { setManualServers(new Set()); setManualNotes(""); setManualStep(1) } }} onSubmit={handleManualBackup} /> void handleDelete()} onCancel={() => setDeleteTarget(null)} /> backup.id === restoreTarget.id) ?? restoreTarget))} error={restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget) ? "Локальный файл отсутствует. Создайте новую резервную копию." : opError} onConfirm={() => void handleRestore()} onCancel={() => setRestoreTarget(null)} />
) }