fix(backups): разделить загрузку каталогов и права действий

This commit is contained in:
Denozordec committed 2026-10-01 15:06:52 +07:00
1 parent b460997cbf
commit 3dcc7c9ea6
11 files changed
+259 -91

No files matched your search

+119 -86
View File
@@ -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 <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[]>([])
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[]>([])
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)
@@ -68,6 +92,7 @@ export default function BackupsPage() {
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())
@@ -78,86 +103,69 @@ export default function BackupsPage() {
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]) =>
setSchedule((s) => ({ ...s, [k]: v }))
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) =>
setStorage((s) => ({ ...s, [k]: v }))
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.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<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(() => {
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}
>
<RefreshCwIcon className="size-4" />
Снять со всех
@@ -386,7 +401,7 @@ export default function BackupsPage() {
type="button"
size="sm"
onClick={() => openCreate(false)}
disabled={loading}
disabled={!canCreate || loading}
>
<PlusIcon className="size-4" />
Новый бэкап
@@ -397,34 +412,46 @@ export default function BackupsPage() {
<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: backupList.length,
value: known("backups") ? backupList.length : "—",
icon: <HardDriveIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "auto",
label: "Авто",
value: autoCount,
value: known("backups") ? autoCount : "—",
icon: <ClockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "manual",
label: "Вручную",
value: manualCount,
value: known("backups") ? manualCount : "—",
icon: <PlusIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "s3",
label: "В S3",
value: s3Count,
value: known("backups") ? s3Count : "—",
icon: <CloudIcon className="size-4" />,
iconClassName: "text-primary",
},
@@ -458,7 +485,7 @@ export default function BackupsPage() {
<TabsTrigger value="settings">Настройки</TabsTrigger>
</TabsList>
<TabsContent value="history" className="flex flex-col gap-5">
<BackupsHistory
{!canRead ? <p role="status">История недоступна без права чтения</p> : !known("backups") ? <p role="status">{resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}</p> : <BackupsHistory
backups={backupList}
kindFilter={kindFilter}
onKindFilterChange={setKindFilter}
@@ -470,9 +497,13 @@ export default function BackupsPage() {
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}
@@ -482,6 +513,7 @@ export default function BackupsPage() {
servers={liveServers}
selectedServers={selectedServers}
onToggleServer={(id) => {
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}
/>
</fieldset>}
</TabsContent>
</Tabs>
</div>