diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index 167034fb..970954a1 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -1,11 +1,11 @@ "use client" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { PageHeader } from "@/components/page-header" -import type { Backup, Server } from "@/lib/data" +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 { BackupStorageSettingsDto } from "@mmapp/contracts/backups" +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" @@ -18,7 +18,9 @@ import { RefreshCwIcon, } from "lucide-react" import { useDataSource } from "@/lib/data-source" -import { listServers } from "@/shared/api/servers" +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, @@ -34,7 +36,7 @@ import { testBackupStorage, type BackupItem, } from "@/shared/api/backups" -import { requestBlob } from "@/shared/api/http-client" +import { requestJson, requestBlob } from "@/shared/api/http-client" import { toast } from "sonner" import { BackupsHistory } from "@/components/backups/backups-history" import { @@ -51,14 +53,36 @@ import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/ba 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 { backendUrl } = useDataSource() + 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([]) + const [backupList, setBackupList] = useState(isLive ? [] : mockBackups) const [kindFilter, setKindFilter] = useState("all") const [search, setSearch] = useState("") const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery()) - const [liveServers, setLiveServers] = useState([]) + 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) @@ -68,6 +92,7 @@ export default function BackupsPage() { 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()) @@ -78,86 +103,69 @@ export default function BackupsPage() { const [deleteTarget, setDeleteTarget] = useState(null) const [restoreTarget, setRestoreTarget] = useState(null) - const setSched = (k: K, v: BackupScheduleForm[K]) => - setSchedule((s) => ({ ...s, [k]: v })) - const setStore = (k: K, v: BackupStorageForm[K]) => - setStorage((s) => ({ ...s, [k]: v })) + 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.max(1, Math.round(b.sizeBytes / 1024)) + 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: `${kb} КБ`, + 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 ?? "local", + 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) - setOpError(null) - try { - const [serversRows, backupsRows, scheduleRow, storageRow] = await Promise.all([ - listServers(backendUrl), - listBackups(backendUrl), - getBackupScheduleSettings(backendUrl), - getBackupStorageSettings(backendUrl), - ]) - const mappedServers = serversRows.map((s) => toFrontendServer(s)) - setLiveServers(mappedServers) - setBackupList(backupsRows.map(mapApiBackupToUi)) - setSchedule({ - enabled: scheduleRow.enabled, - frequency: scheduleRow.frequency, - hour: scheduleRow.hour, - minute: scheduleRow.minute, - weekDay: scheduleRow.weekDay, - monthDay: scheduleRow.monthDay, - keepCount: scheduleRow.keepCount, - format: scheduleRow.format, - }) - setSavedStorage(storageRow) - setScheduleLastError(scheduleRow.lastError ?? null) - setStorage((prev) => ({ - ...prev, - provider: storageRow.provider, - s3Endpoint: storageRow.s3Endpoint, - s3Region: storageRow.s3Region, - s3Bucket: storageRow.s3Bucket, - s3Prefix: storageRow.s3Prefix, - s3AccessKeyId: storageRow.s3AccessKeyId, - s3SecretAccessKey: "", - s3ForcePathStyle: storageRow.s3ForcePathStyle, - keepLocalCopy: storageRow.keepLocalCopy, - })) - const serverIds = scheduleRow.serverIds.length > 0 - ? scheduleRow.serverIds - : mappedServers.map((s) => s.id) - setSelectedServers(new Set(serverIds.filter((id) => mappedServers.some((s) => s.id === id)))) - } catch (e) { - setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных") - } finally { - setLoading(false) - } - }, [backendUrl, mapApiBackupToUi]) - + 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(() => { - void loadLive() + alive.current = true + queueMicrotask(() => { if (alive.current) void loadLive() }) + return () => { alive.current = false; reader.current?.abort() } }, [loadLive]) async function handleSave() { - if (scheduleSaveBusy) return + if (!canWrite || !current("schedule") || !current("storage") || scheduleSaveBusy) return setScheduleSaveBusy(true) setOpError(null) try { - await putBackupScheduleSettings(backendUrl, { + const confirmedSchedule = await putBackupScheduleSettings(backendUrl, { enabled: schedule.enabled, frequency: schedule.frequency, hour: schedule.hour, @@ -168,6 +176,7 @@ export default function BackupsPage() { format: "rsc", serverIds: [...selectedServers], }) + setSavedSchedule(confirmedSchedule) const saved = await putBackupStorageSettings(backendUrl, { provider: storage.provider, s3Endpoint: storage.s3Endpoint, @@ -190,6 +199,7 @@ export default function BackupsPage() { } async function handleTestStorage() { + if (!canWrite || !current("storage")) return setTestBusy(true) try { await putBackupStorageSettings(backendUrl, { @@ -219,6 +229,7 @@ export default function BackupsPage() { } async function handleSyncStorage() { + if (!canWrite || !current("storage")) return setSyncBusy(true) try { const result = await syncBackupsFromStorage(backendUrl) @@ -232,7 +243,7 @@ export default function BackupsPage() { } async function handleManualBackup() { - if (manualServers.size === 0 || opBusy) return + if (!canCreate || manualServers.size === 0 || opBusy) return setOpError(null) try { const res = await createBackupsAsync(backendUrl, { @@ -291,7 +302,7 @@ export default function BackupsPage() { }, [opError]) async function handleDelete() { - if (!deleteTarget) return + if (!canWrite || !deleteTarget) return setOpBusy(true) setOpError(null) try { @@ -306,7 +317,7 @@ export default function BackupsPage() { } async function handleRestore() { - if (!restoreTarget) return + if (!canWrite || !restoreTarget) return setOpBusy(true) setOpError(null) try { @@ -321,6 +332,7 @@ export default function BackupsPage() { } 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) @@ -336,19 +348,21 @@ export default function BackupsPage() { const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length const schedSummary = (() => { - if (!schedule.enabled) return "Расписание отключено" - const t = `${String(schedule.hour).padStart(2, "0")}:${String(schedule.minute).padStart(2, "0")}` - if (schedule.frequency === "daily") return `Каждый день в ${t}` - if (schedule.frequency === "weekly") return `Каждую неделю (${WEEK_DAYS[schedule.weekDay]}) в ${t}` - return `${schedule.monthDay}-го числа каждого месяца в ${t}` + 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 destinationLabel = storage.provider === "s3" - ? (storage.keepLocalCopy ? "локально и в S3" : "в S3") + const confirmedStorage = isLive ? savedStorage : defaultStorageForm + const destinationLabel = !confirmedStorage ? "Хранилище пока неизвестно" : confirmedStorage.provider === "s3" + ? (confirmedStorage.keepLocalCopy ? "локально и в S3" : "в S3") : "локально (storage/backups)" const statusDetail = [ - backupJobId ? null : `${destinationLabel} · ${selectedServers.size} из ${liveServers.length} серверов`, + backupJobId ? null : `${destinationLabel} · ${known("servers") ? liveServers.length + " серверов в каталоге" : "Серверы пока неизвестны"}`, scheduleLastError, savedStorage?.lastTestError, ].filter(Boolean).join(" · ") @@ -360,6 +374,7 @@ export default function BackupsPage() { : "default" function openCreate(all: boolean) { + if (!canCreate) return setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set()) setManualNotes("") setManualStep(1) @@ -377,7 +392,7 @@ export default function BackupsPage() { variant="outline" size="sm" onClick={() => openCreate(true)} - disabled={loading} + disabled={!canCreate || loading} > Снять со всех @@ -386,7 +401,7 @@ export default function BackupsPage() { type="button" size="sm" onClick={() => openCreate(false)} - disabled={loading} + disabled={!canCreate || loading} > Новый бэкап @@ -397,34 +412,46 @@ export default function BackupsPage() {
+ {!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: autoCount, + value: known("backups") ? autoCount : "—", icon: , iconClassName: "text-info", }, { id: "manual", label: "Вручную", - value: manualCount, + value: known("backups") ? manualCount : "—", icon: , iconClassName: "text-muted-foreground", }, { id: "s3", label: "В S3", - value: s3Count, + value: known("backups") ? s3Count : "—", icon: , iconClassName: "text-primary", }, @@ -458,7 +485,7 @@ export default function BackupsPage() { Настройки - История недоступна без права чтения

: !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) @@ -489,8 +521,8 @@ export default function BackupsPage() { return next }) }} - onSelectAll={() => setSelectedServers(new Set(liveServers.map((s) => s.id)))} - onClearServers={() => setSelectedServers(new Set())} + 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} @@ -498,6 +530,7 @@ export default function BackupsPage() { testBusy={testBusy} syncBusy={syncBusy} /> +
}
diff --git a/components/backups/backups-history.tsx b/components/backups/backups-history.tsx index 79b18dfc..d8965cbb 100644 --- a/components/backups/backups-history.tsx +++ b/components/backups/backups-history.tsx @@ -28,6 +28,9 @@ export function BackupsHistory({ onRestore, onDelete, onCreate, + canCreate = true, + canWrite = true, + canDownload = true, }: { backups: Backup[] kindFilter: KindFilter @@ -39,6 +42,9 @@ export function BackupsHistory({ onDownload: (id: string, filename: string) => void onRestore: (backup: Backup) => void onDelete: (backup: Backup) => void + canCreate?: boolean + canWrite?: boolean + canDownload?: boolean onCreate: () => void }) { const autoCount = backups.filter((b) => b.kind === "auto").length @@ -76,6 +82,8 @@ export function BackupsHistory({ onDownload={onDownload} onRestore={onRestore} onDelete={onDelete} + canWrite={canWrite} + canDownload={canDownload} emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"} emptyDescription={ isEmptyAll @@ -83,7 +91,7 @@ export function BackupsHistory({ : "Измените фильтры или поисковый запрос" } emptyAction={ - isEmptyAll ? ( + isEmptyAll && canCreate ? (