"use client" import { useCallback, useEffect, useRef, useState } from "react" import { PageHeader } from "@/components/page-header" import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data" import { createFilterQuery } from "@/components/reui/filters/filters-query" import type { FilterQuery } from "@/components/reui/filters/filters-types" import type { BackupScheduleSettingsDto, 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 { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, getBackupStorageSettings, listBackups, putBackupScheduleSettings, putBackupStorageSettings, restoreBackup, syncBackupsFromStorage, testBackupStorage, type BackupItem, } from "@/shared/api/backups" import { requestJson, 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" 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() { 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 reader = useRef(null) const revision = useRef(0) const alive = useRef(true) const scheduleDirty = useRef(false) const storageDirty = useRef(false) const known = (resource: Resource) => !isLive || resources[resource].known const current = (resource: Resource) => known(resource) && !resources[resource].error const [tab, setTab] = useState("history") const [backupList, setBackupList] = useState(isLive ? [] : mockBackups) const [kindFilter, setKindFilter] = useState("all") const [search, setSearch] = useState("") const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery()) const [liveServers, setLiveServers] = useState(isLive ? [] : mockServers) const canCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage") const [loading, setLoading] = useState(false) const [opBusy, setOpBusy] = useState(false) const [opError, setOpError] = useState(null) const [backupJobId, setBackupJobId] = 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 [savedSchedule, setSavedSchedule] = useState(null) const [savedStorage, setSavedStorage] = useState(null) const [scheduleLastError, setScheduleLastError] = useState(null) 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]) => { storageDirty.current = 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, } }, []) 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(toFrontendServer)); 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)) } 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 queueMicrotask(() => { if (alive.current) void loadLive() }) return () => { alive.current = false; reader.current?.abort() } }, [loadLive]) async function handleSave() { if (!canWrite || !current("schedule") || !current("storage") || scheduleSaveBusy) return setScheduleSaveBusy(true) setOpError(null) try { const confirmedSchedule = 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], }) setSavedSchedule(confirmedSchedule) 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() { if (!canWrite || !current("storage")) return 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() { if (!canWrite || !current("storage")) return 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 (!canCreate || 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 (!canWrite || !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 (!canWrite || !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) { if (!isLive || !canRead) return 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 = (() => { 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[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 (!canCreate) return setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set()) setManualNotes("") setManualStep(1) setManualOpen(true) } return (
} />
{!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} )} , 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 ? "Задача выполняется" : schedSummary} {backupJobId ? "Снимок конфигурации снимается в фоне. Список обновится после завершения." : statusDetail} setTab(String(v) as PageTab)} className="gap-5" > История бэкапов Настройки {!canRead ?

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

: !known("backups") ?

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

: openCreate(false)} canCreate={canCreate} canWrite={canWrite} canDownload={isLive && canRead} />}
{(!known("schedule") || !known("storage")) ?

Настройки резервных копий пока неизвестны

:
{ 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; setSelectedServers(new Set(liveServers.map(s => s.id))) }} onClearServers={() => { scheduleDirty.current = true; setSelectedServers(new Set()) }} onSave={handleSave} 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={manualNotes} onNotesChange={setManualNotes} destinationLabel={destinationLabel} busy={backupJobId !== null} onSubmit={handleManualBackup} /> void handleDelete()} onCancel={() => setDeleteTarget(null)} /> void handleRestore()} onCancel={() => setRestoreTarget(null)} />
) }