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

578 lines
24 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 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 <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 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 [savedSchedule, setSavedSchedule] = useState<BackupScheduleSettingsDto | null>(null)
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]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) }
const setStore = <K extends keyof BackupStorageForm>(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<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)) }
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 (
<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}>Обновить</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>)}
<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">
{(!known("schedule") || !known("storage")) ? <p role="status">Настройки резервных копий пока неизвестны</p> : <fieldset disabled={!canWrite || !current("schedule") || !current("storage")} className="min-w-0">
<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; 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}
/>
</fieldset>}
</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>
)
}