Files
MikrotikManager/app/(main)/backups/page.tsx
T

590 lines
28 KiB
TypeScript
Raw 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, 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 { 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 {
createBackupsAsync,
deleteBackup,
getBackupJob,
getBackupScheduleSettings,
getBackupStorageSettings,
listBackups,
putBackupScheduleSettings,
putBackupStorageSettings,
restoreBackup,
syncBackupsFromStorage,
testBackupStorage,
type BackupItem,
} from "@/shared/api/backups"
import { ApiClientError, requestJson, requestBlob } from "@/shared/api/http-client"
import { toast } from "sonner"
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
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 <p role="status" className="p-5">Загрузка настроек источника…</p>
return <BackupsContent key={mode + ":" + backendUrl} />
}
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<Record<Resource, ResourceState>>(() => ({ backups: unknownResource(), servers: unknownResource(), schedule: unknownResource(), storage: unknownResource() }))
const reader = useRef<AbortController | null>(null)
const revision = useRef(0)
const alive = useRef(true)
const scheduleDirty = useRef(false)
const storageDirty = useRef(false)
const settingsLock = useRef(false)
const settingsController = useRef<AbortController | null>(null)
const known = (resource: Resource) => !isLive || resources[resource].known
const current = (resource: Resource) => known(resource) && !resources[resource].error
const [tab, setTab] = useState<PageTab>("history")
const [backupList, setBackupList] = useState<Backup[]>(isLive ? [] : mockBackups)
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
const [search, setSearch] = useState("")
const [advancedFilters, setAdvancedFilters] = useState<FilterQuery>(() => createFilterQuery())
const [liveServers, setLiveServers] = useState<Server[]>(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<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 [storageIsDirty, setStorageIsDirty] = useState(false)
const [savedSchedule, setSavedSchedule] = useState<BackupScheduleSettingsDto | null>(null)
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
const [allEnabled, setAllEnabled] = useState(true)
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]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) }
const setStore = <K extends keyof BackupStorageForm>(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,
}
}, [])
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<unknown>(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)); 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
queueMicrotask(() => { if (alive.current) void loadLive() })
return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort() }
}, [loadLive])
function startSettingsMutation() {
if (!alive.current || settingsLock.current || opBusy || 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 (!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[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 (!canCreate) return
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={!canCreate || loading}
>
<RefreshCwIcon className="size-4" />
Снять со всех
</Button>
<Button
type="button"
size="sm"
onClick={() => openCreate(false)}
disabled={!canCreate || loading}
>
<PlusIcon className="size-4" />
Новый бэкап
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демонстрационные данные. Операции и сохранение недоступны.</p>}
{!canRead && <p role="status">Нет права mm:backups:read</p>}
{isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers:read.</p>}
{isLive && canRead && <div className="flex items-center gap-3 text-sm text-muted-foreground">
<Button type="button" variant="outline" size="sm" onClick={() => void loadLive()} disabled={loading || scheduleSaveBusy || opBusy}>Обновить</Button>
<span role="status">{loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}</span>
</div>}
{isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource => <Alert key={resource} variant="warning" aria-label={"Данные резервных копий: " + resource}>
<AlertTitle>{resources[resource].known ? "Последние данные сохранены" : "Данные пока неизвестны"}</AlertTitle>
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
</Alert>)}
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
<KpiStatGrid
aria-label="Сводка бэкапов"
items={[
{
id: "all",
label: "Всего бэкапов",
value: known("backups") ? backupList.length : "—",
icon: <HardDriveIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "auto",
label: "Авто",
value: known("backups") ? autoCount : "—",
icon: <ClockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "manual",
label: "Вручную",
value: known("backups") ? manualCount : "—",
icon: <PlusIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "s3",
label: "В S3",
value: known("backups") ? 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">
{!canRead ? <p role="status">История недоступна без права чтения</p> : !known("backups") ? <p role="status">{resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}</p> : <BackupsHistory
backups={backupList}
kindFilter={kindFilter}
onKindFilterChange={setKindFilter}
search={search}
onSearchChange={setSearch}
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
onDownload={handleDownload}
onRestore={setRestoreTarget}
onDelete={setDeleteTarget}
onCreate={() => openCreate(false)}
canCreate={canCreate}
canWrite={canWrite}
canDownload={isLive && canRead}
/>}
</TabsContent>
<TabsContent value="settings">
<BackupsSettings
schedule={schedule}
onScheduleChange={setSched}
storage={storage}
onStorageChange={setStore}
savedStorage={savedStorage}
servers={liveServers}
selectedServers={selectedServers}
onToggleServer={(id) => {
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")}
storageWritable={canWrite && current("storage")}
allEnabled={allEnabled}
onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }}
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>
)
}