feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,623 @@
|
||||
"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 <Suspense fallback={<p role="status" className="p-5">Загрузка контекста резервных копий…</p>}><BackupsSource /></Suspense> }
|
||||
function BackupsSource() {
|
||||
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 dialogTrigger = useRef<{ element: HTMLElement; rowId: string | null; label: string | null } | null>(null)
|
||||
const refreshTrigger = useRef<HTMLButtonElement | null>(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<HTMLElement>(`[data-row-id="${CSS.escape(trigger.rowId)}"] button[aria-label="${CSS.escape(trigger.label)}"]`)
|
||||
if (replacement) return replacement
|
||||
}
|
||||
return refreshTrigger.current
|
||||
}
|
||||
const reader = useRef<AbortController | null>(null)
|
||||
const revision = useRef(0)
|
||||
const alive = useRef(true)
|
||||
const scheduleDirty = useRef(false)
|
||||
const storageDirty = useRef(false)
|
||||
const operationLock = useRef(false)
|
||||
const operationController = useRef<AbortController | null>(null)
|
||||
const downloadControllers = useRef(new Map<string, AbortController>())
|
||||
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, kindFilter, setKindFilter, search, setSearch, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, reset: resetContext } = useBackupsUrlState()
|
||||
const [backupList, setBackupList] = useState<Backup[]>(isLive ? [] : mockBackups)
|
||||
const [liveServers, setLiveServers] = useState<Server[]>(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<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,
|
||||
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<unknown>(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 (
|
||||
<div className="flex min-h-0 min-w-0 flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openCreate(true)}
|
||||
disabled={!canCreate || loading || job.busy}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Снять со всех
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => openCreate(false)}
|
||||
disabled={checkpoint ? !canRead : !canCreate || loading || job.busy}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
{checkpoint ? "Задача резервного копирования" : "Новый бэкап"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{urlError && <Alert variant="warning" aria-label="Контекст резервных копий"><AlertTitle>Не удалось прочитать ссылку</AlertTitle><AlertDescription>{urlError}</AlertDescription><AlertAction><Button type="button" variant="outline" onClick={resetContext}>Сбросить контекст</Button></AlertAction></Alert>}
|
||||
{!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 flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<Button type="button" variant="outline" size="sm" ref={refreshTrigger} 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>)}
|
||||
|
||||
{isLive && (restoreGuard.checkpoint || restoreGuard.error) && <Alert variant="warning" aria-label="Подтверждение восстановления">
|
||||
<AlertTitle>{opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"}</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3">
|
||||
<span>{restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")}</span>
|
||||
<label className="flex items-start gap-2"><Checkbox checked={restoreReviewed} disabled={opBusy || !canWrite} onCheckedChange={value => setRestoreReviewed(value === true)} />Я проверил состояние роутера и понимаю последствия повторного импорта</label>
|
||||
<Button type="button" variant="outline" disabled={!restoreReviewed || opBusy || !canWrite} onClick={() => { if (restoreGuard.clear()) { setRestoreReviewed(false); setOpError(null) } }}>Разрешить новое восстановление</Button>
|
||||
</AlertDescription>
|
||||
</Alert>}
|
||||
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
|
||||
{(job.error || job.storageError || persistedJobError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError || persistedJobError}</AlertDescription></Alert>}
|
||||
<div><h1 className="text-xl font-semibold">Резервные копии</h1><p className="text-sm text-muted-foreground">История файлов, расписание и подтверждения операций</p></div>
|
||||
<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 ? (checkpoint?.accepted ? "Задача наблюдается" : "Результат запроса пока неизвестен") : schedSummary}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{backupJobId
|
||||
? "Закрытие формы не отменяет задачу. Проверка продолжает чтение того же ID."
|
||||
: statusDetail}
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button type="button" variant="ghost" size="xs" onClick={() => setTab("settings")}>
|
||||
Настройки
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
|
||||
<Frame dense className="min-w-0"><FramePanel className="min-w-0 p-3 sm:p-5">
|
||||
<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">
|
||||
{urlError ? null : !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={backup => { 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}
|
||||
/>}
|
||||
</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") && !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}
|
||||
/>
|
||||
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</FramePanel></Frame>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackupCreateSheet
|
||||
open={manualOpen}
|
||||
onOpenChange={setManualOpen}
|
||||
step={manualStep}
|
||||
onStepChange={setManualStep}
|
||||
servers={liveServers}
|
||||
selected={checkpoint ? new Set(checkpoint.request.serverIds.map(String)) : 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={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}
|
||||
/>
|
||||
|
||||
<BackupDeleteDialog
|
||||
returnFocus={returnDialogFocus}
|
||||
backup={deleteTarget}
|
||||
busy={opBusy}
|
||||
error={opError}
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
<BackupRestoreDialog
|
||||
returnFocus={returnDialogFocus}
|
||||
backup={restoreTarget}
|
||||
busy={opBusy}
|
||||
blocked={!restoreGuard.canStart || Boolean(restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget))}
|
||||
error={restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget) ? "Локальный файл отсутствует. Создайте новую резервную копию." : opError}
|
||||
onConfirm={() => void handleRestore()}
|
||||
onCancel={() => setRestoreTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user