fix(backups): сохранять расписание и хранилище независимо
This commit is contained in:
1 parent
5ce21e9a47
commit
8e01666e0e
12 files changed
+286
-104
No files matched your search
+76
-74
@@ -38,6 +38,7 @@ import {
|
||||
} from "@/shared/api/backups"
|
||||
import { 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,
|
||||
@@ -74,6 +75,8 @@ function BackupsContent() {
|
||||
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")
|
||||
@@ -95,6 +98,7 @@ function BackupsContent() {
|
||||
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)
|
||||
@@ -142,7 +146,7 @@ function BackupsContent() {
|
||||
}).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)) }
|
||||
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 => {
|
||||
@@ -157,89 +161,79 @@ function BackupsContent() {
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
queueMicrotask(() => { if (alive.current) void loadLive() })
|
||||
return () => { alive.current = false; reader.current?.abort() }
|
||||
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("storage") || scheduleSaveBusy) return
|
||||
setScheduleSaveBusy(true)
|
||||
setOpError(null)
|
||||
if (!canWrite || !current("schedule") || !current("servers") || !canReadServers) return
|
||||
const abort = startSettingsMutation(); if (!abort) return
|
||||
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)
|
||||
}
|
||||
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), { signal: abort.signal })
|
||||
if (!alive.current || abort.signal.aborted) return
|
||||
setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = 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 {
|
||||
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)
|
||||
}
|
||||
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) 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)
|
||||
toast.success(`Импортировано: ${result.imported}, пропущено: ${result.skipped}`)
|
||||
const result = await syncBackupsFromStorage(backendUrl, { signal: abort.signal })
|
||||
if (!alive.current || abort.signal.aborted) return
|
||||
toast.success("Импортировано: " + result.imported + ", пропущено: " + result.skipped)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка синхронизации")
|
||||
} finally {
|
||||
setSyncBusy(false)
|
||||
}
|
||||
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
||||
finally { finishSettingsMutation(abort) }
|
||||
}
|
||||
|
||||
async function handleManualBackup() {
|
||||
@@ -353,7 +347,7 @@ function BackupsContent() {
|
||||
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
|
||||
if (confirmed.frequency === "weekly") return "Каждую неделю (" + WEEK_DAYS[Number(confirmed.weekDay)] + ") в " + time
|
||||
return confirmed.monthDay + "-го числа каждого месяца в " + time
|
||||
})()
|
||||
const confirmedStorage = isLive ? savedStorage : defaultStorageForm
|
||||
@@ -416,7 +410,7 @@ function BackupsContent() {
|
||||
{!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>
|
||||
<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}>
|
||||
@@ -424,6 +418,7 @@ function BackupsContent() {
|
||||
<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={[
|
||||
@@ -503,7 +498,7 @@ function BackupsContent() {
|
||||
/>}
|
||||
</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}
|
||||
@@ -521,16 +516,23 @@ function BackupsContent() {
|
||||
return next
|
||||
})
|
||||
}}
|
||||
onSelectAll={() => { scheduleDirty.current = true; setSelectedServers(new Set(liveServers.map(s => s.id))) }}
|
||||
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")}
|
||||
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}
|
||||
/>
|
||||
</fieldset>}
|
||||
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user