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"
|
} from "@/shared/api/backups"
|
||||||
import { requestJson, requestBlob } from "@/shared/api/http-client"
|
import { requestJson, requestBlob } from "@/shared/api/http-client"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
|
||||||
import { BackupsHistory } from "@/components/backups/backups-history"
|
import { BackupsHistory } from "@/components/backups/backups-history"
|
||||||
import {
|
import {
|
||||||
BackupsSettings,
|
BackupsSettings,
|
||||||
@@ -74,6 +75,8 @@ function BackupsContent() {
|
|||||||
const alive = useRef(true)
|
const alive = useRef(true)
|
||||||
const scheduleDirty = useRef(false)
|
const scheduleDirty = useRef(false)
|
||||||
const storageDirty = 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 known = (resource: Resource) => !isLive || resources[resource].known
|
||||||
const current = (resource: Resource) => known(resource) && !resources[resource].error
|
const current = (resource: Resource) => known(resource) && !resources[resource].error
|
||||||
const [tab, setTab] = useState<PageTab>("history")
|
const [tab, setTab] = useState<PageTab>("history")
|
||||||
@@ -95,6 +98,7 @@ function BackupsContent() {
|
|||||||
const [savedSchedule, setSavedSchedule] = useState<BackupScheduleSettingsDto | null>(null)
|
const [savedSchedule, setSavedSchedule] = useState<BackupScheduleSettingsDto | null>(null)
|
||||||
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
|
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
|
||||||
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
|
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
|
||||||
|
const [allEnabled, setAllEnabled] = useState(true)
|
||||||
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
|
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
|
||||||
const [manualOpen, setManualOpen] = useState(false)
|
const [manualOpen, setManualOpen] = useState(false)
|
||||||
const [manualStep, setManualStep] = useState(1)
|
const [manualStep, setManualStep] = useState(1)
|
||||||
@@ -142,7 +146,7 @@ function BackupsContent() {
|
|||||||
}).catch(error => failure("servers", error)) : Promise.resolve(),
|
}).catch(error => failure("servers", error)) : Promise.resolve(),
|
||||||
getBackupScheduleSettings(backendUrl, { signal }).then(row => {
|
getBackupScheduleSettings(backendUrl, { signal }).then(row => {
|
||||||
if (!active()) return
|
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")
|
setSavedSchedule(row); setScheduleLastError(row.lastError ?? null); success("schedule")
|
||||||
}).catch(error => failure("schedule", error)),
|
}).catch(error => failure("schedule", error)),
|
||||||
getBackupStorageSettings(backendUrl, { signal }).then(row => {
|
getBackupStorageSettings(backendUrl, { signal }).then(row => {
|
||||||
@@ -157,89 +161,79 @@ function BackupsContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
alive.current = true
|
alive.current = true
|
||||||
queueMicrotask(() => { if (alive.current) void loadLive() })
|
queueMicrotask(() => { if (alive.current) void loadLive() })
|
||||||
return () => { alive.current = false; reader.current?.abort() }
|
return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort() }
|
||||||
}, [loadLive])
|
}, [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() {
|
async function handleSave() {
|
||||||
if (!canWrite || !current("schedule") || !current("storage") || scheduleSaveBusy) return
|
if (!canWrite || !current("schedule") || !current("servers") || !canReadServers) return
|
||||||
setScheduleSaveBusy(true)
|
const abort = startSettingsMutation(); if (!abort) return
|
||||||
setOpError(null)
|
|
||||||
try {
|
try {
|
||||||
const confirmedSchedule = await putBackupScheduleSettings(backendUrl, {
|
const payload = backupSchedulePayload(schedule, [...selectedServers], allEnabled)
|
||||||
enabled: schedule.enabled,
|
if (payload.serverIds?.some(id => !liveServers.some(server => server.id === id))) throw new Error("Выбранный сервер отсутствует; обновите или измените выбор")
|
||||||
frequency: schedule.frequency,
|
const result = await putBackupScheduleSettings(backendUrl, payload, { signal: abort.signal })
|
||||||
hour: schedule.hour,
|
if (!alive.current || abort.signal.aborted) return
|
||||||
minute: schedule.minute,
|
setSavedSchedule(result); setSchedule(result); setScheduleLastError(result.lastError ?? null); scheduleDirty.current = false
|
||||||
weekDay: schedule.weekDay,
|
setSelectedServers(new Set(result.serverIds)); setAllEnabled(result.serverIds.length === 0); settingsConfirmed("schedule")
|
||||||
monthDay: schedule.monthDay,
|
toast.success("Расписание сохранено")
|
||||||
keepCount: schedule.keepCount,
|
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
||||||
format: "rsc",
|
finally { finishSettingsMutation(abort) }
|
||||||
serverIds: [...selectedServers],
|
}
|
||||||
})
|
async function handleSaveStorage() {
|
||||||
setSavedSchedule(confirmedSchedule)
|
if (!canWrite || !current("storage")) return
|
||||||
const saved = await putBackupStorageSettings(backendUrl, {
|
const abort = startSettingsMutation(); if (!abort) return
|
||||||
provider: storage.provider,
|
try {
|
||||||
s3Endpoint: storage.s3Endpoint,
|
const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage), { signal: abort.signal })
|
||||||
s3Region: storage.s3Region,
|
if (!alive.current || abort.signal.aborted) return
|
||||||
s3Bucket: storage.s3Bucket,
|
setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; settingsConfirmed("storage")
|
||||||
s3Prefix: storage.s3Prefix,
|
toast.success("Хранилище сохранено")
|
||||||
s3AccessKeyId: storage.s3AccessKeyId,
|
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
||||||
s3SecretAccessKey: storage.s3SecretAccessKey || undefined,
|
finally { finishSettingsMutation(abort) }
|
||||||
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() {
|
async function handleTestStorage() {
|
||||||
if (!canWrite || !current("storage")) return
|
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)
|
setTestBusy(true)
|
||||||
try {
|
try {
|
||||||
await putBackupStorageSettings(backendUrl, {
|
const result = await testBackupStorage(backendUrl, { signal: abort.signal })
|
||||||
provider: storage.provider,
|
if (!alive.current || abort.signal.aborted) return
|
||||||
s3Endpoint: storage.s3Endpoint,
|
setSavedStorage(result); settingsConfirmed("storage")
|
||||||
s3Region: storage.s3Region,
|
if (result.lastTestError || !result.lastTestAt || result.provider !== "s3") throw new Error(result.lastTestError || "Проверка S3 не подтверждена")
|
||||||
s3Bucket: storage.s3Bucket,
|
toast.success("Доступ к S3 подтверждён")
|
||||||
s3Prefix: storage.s3Prefix,
|
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
||||||
s3AccessKeyId: storage.s3AccessKeyId,
|
finally { finishSettingsMutation(abort) }
|
||||||
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() {
|
async function handleSyncStorage() {
|
||||||
if (!canWrite || !current("storage")) return
|
if (!canWrite || !current("storage")) return
|
||||||
|
if (storageDirty.current) { setOpError("Сначала сохраните хранилище; синхронизация использует подтверждённые настройки"); return }
|
||||||
|
if (savedStorage?.provider !== "s3") return
|
||||||
|
const abort = startSettingsMutation(); if (!abort) return
|
||||||
setSyncBusy(true)
|
setSyncBusy(true)
|
||||||
try {
|
try {
|
||||||
const result = await syncBackupsFromStorage(backendUrl)
|
const result = await syncBackupsFromStorage(backendUrl, { signal: abort.signal })
|
||||||
toast.success(`Импортировано: ${result.imported}, пропущено: ${result.skipped}`)
|
if (!alive.current || abort.signal.aborted) return
|
||||||
|
toast.success("Импортировано: " + result.imported + ", пропущено: " + result.skipped)
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка синхронизации")
|
finally { finishSettingsMutation(abort) }
|
||||||
} finally {
|
|
||||||
setSyncBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleManualBackup() {
|
async function handleManualBackup() {
|
||||||
@@ -353,7 +347,7 @@ function BackupsContent() {
|
|||||||
if (!confirmed.enabled) return "Расписание отключено"
|
if (!confirmed.enabled) return "Расписание отключено"
|
||||||
const time = String(confirmed.hour).padStart(2, "0") + ":" + String(confirmed.minute).padStart(2, "0")
|
const time = String(confirmed.hour).padStart(2, "0") + ":" + String(confirmed.minute).padStart(2, "0")
|
||||||
if (confirmed.frequency === "daily") return "Каждый день в " + time
|
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
|
return confirmed.monthDay + "-го числа каждого месяца в " + time
|
||||||
})()
|
})()
|
||||||
const confirmedStorage = isLive ? savedStorage : defaultStorageForm
|
const confirmedStorage = isLive ? savedStorage : defaultStorageForm
|
||||||
@@ -416,7 +410,7 @@ function BackupsContent() {
|
|||||||
{!canRead && <p role="status">Нет права mm:backups:read</p>}
|
{!canRead && <p role="status">Нет права mm:backups:read</p>}
|
||||||
{isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers: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">
|
{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>
|
<span role="status">{loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}</span>
|
||||||
</div>}
|
</div>}
|
||||||
{isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource => <Alert key={resource} variant="warning" aria-label={"Данные резервных копий: " + resource}>
|
{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>
|
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
|
||||||
</Alert>)}
|
</Alert>)}
|
||||||
|
|
||||||
|
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка бэкапов"
|
aria-label="Сводка бэкапов"
|
||||||
items={[
|
items={[
|
||||||
@@ -503,7 +498,7 @@ function BackupsContent() {
|
|||||||
/>}
|
/>}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="settings">
|
<TabsContent value="settings">
|
||||||
{(!known("schedule") || !known("storage")) ? <p role="status">Настройки резервных копий пока неизвестны</p> : <fieldset disabled={!canWrite || !current("schedule") || !current("storage")} className="min-w-0">
|
|
||||||
<BackupsSettings
|
<BackupsSettings
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
onScheduleChange={setSched}
|
onScheduleChange={setSched}
|
||||||
@@ -521,16 +516,23 @@ function BackupsContent() {
|
|||||||
return next
|
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()) }}
|
onClearServers={() => { scheduleDirty.current = true; setSelectedServers(new Set()) }}
|
||||||
onSave={handleSave}
|
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}
|
onTest={handleTestStorage}
|
||||||
onSync={handleSyncStorage}
|
onSync={handleSyncStorage}
|
||||||
saveBusy={scheduleSaveBusy}
|
saveBusy={scheduleSaveBusy}
|
||||||
testBusy={testBusy}
|
testBusy={testBusy}
|
||||||
syncBusy={syncBusy}
|
syncBusy={syncBusy}
|
||||||
/>
|
/>
|
||||||
</fieldset>}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export type StorageProvider = "local" | "s3"
|
|||||||
export type BackupScheduleForm = {
|
export type BackupScheduleForm = {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
frequency: BackupFreq
|
frequency: BackupFreq
|
||||||
hour: number
|
hour: number | string
|
||||||
minute: number
|
minute: number | string
|
||||||
weekDay: number
|
weekDay: number | string
|
||||||
monthDay: number
|
monthDay: number | string
|
||||||
keepCount: number
|
keepCount: number | string
|
||||||
format: "rsc" | "backup"
|
format: "rsc" | "backup"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +93,13 @@ export function BackupsSettings({
|
|||||||
onSelectAll,
|
onSelectAll,
|
||||||
onClearServers,
|
onClearServers,
|
||||||
onSave,
|
onSave,
|
||||||
|
onSaveStorage,
|
||||||
|
scheduleKnown = true,
|
||||||
|
storageKnown = true,
|
||||||
|
scheduleWritable = true,
|
||||||
|
storageWritable = true,
|
||||||
|
allEnabled = false,
|
||||||
|
onAllEnabledChange,
|
||||||
onTest,
|
onTest,
|
||||||
onSync,
|
onSync,
|
||||||
saveBusy,
|
saveBusy,
|
||||||
@@ -109,6 +116,13 @@ export function BackupsSettings({
|
|||||||
onToggleServer: (id: string) => void
|
onToggleServer: (id: string) => void
|
||||||
onSelectAll: () => void
|
onSelectAll: () => void
|
||||||
onClearServers: () => void
|
onClearServers: () => void
|
||||||
|
onSaveStorage: () => void
|
||||||
|
scheduleKnown?: boolean
|
||||||
|
storageKnown?: boolean
|
||||||
|
scheduleWritable?: boolean
|
||||||
|
storageWritable?: boolean
|
||||||
|
allEnabled?: boolean
|
||||||
|
onAllEnabledChange: (value: boolean) => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onTest: () => void
|
onTest: () => void
|
||||||
onSync: () => void
|
onSync: () => void
|
||||||
@@ -121,16 +135,17 @@ export function BackupsSettings({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
|
{!scheduleKnown ? <p role="status">Расписание пока неизвестно</p> : <fieldset disabled={!scheduleWritable || saveBusy || testBusy || syncBusy} className="min-w-0">
|
||||||
<OpsPanel title="Расписание" description="Автоматический съём конфигурации" contentClassName="px-5 py-5 flex flex-col gap-5">
|
<OpsPanel title="Расписание" description="Автоматический съём конфигурации" contentClassName="px-5 py-5 flex flex-col gap-5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Автоматический бэкап</p>
|
<p className="text-sm font-medium">Автоматический бэкап</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
||||||
</div>
|
</div>
|
||||||
<FormToggle checked={schedule.enabled} onChange={(v) => onScheduleChange("enabled", v)} />
|
<FormToggle ariaLabel="Автоматический бэкап" checked={schedule.enabled} onChange={(v) => onScheduleChange("enabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("flex flex-col gap-4", !schedule.enabled && "opacity-40 pointer-events-none")}>
|
<div className="flex flex-col gap-4">
|
||||||
<FormField label="Частота">
|
<FormField label="Частота">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={schedule.frequency}
|
value={schedule.frequency}
|
||||||
@@ -172,8 +187,9 @@ export function BackupsSettings({
|
|||||||
min={1}
|
min={1}
|
||||||
max={28}
|
max={28}
|
||||||
className="font-mono w-24"
|
className="font-mono w-24"
|
||||||
|
aria-label="День месяца"
|
||||||
value={schedule.monthDay}
|
value={schedule.monthDay}
|
||||||
onChange={(e) => onScheduleChange("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))}
|
onChange={(e) => onScheduleChange("monthDay", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
@@ -185,10 +201,12 @@ export function BackupsSettings({
|
|||||||
min={0}
|
min={0}
|
||||||
max={23}
|
max={23}
|
||||||
className="font-mono w-20 text-center"
|
className="font-mono w-20 text-center"
|
||||||
value={String(schedule.hour).padStart(2, "0")}
|
aria-label="Час запуска"
|
||||||
onChange={(e) => onScheduleChange("hour", Math.min(23, Math.max(0, Number(e.target.value))))}
|
value={schedule.hour}
|
||||||
|
onChange={(e) => onScheduleChange("hour", e.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground font-mono text-lg">:</span>
|
<span className="text-muted-foreground font-mono text-lg">:</span>
|
||||||
|
<Input aria-label="Минута запуска" type="number" min={0} max={59} className="font-mono w-20 text-center" value={schedule.minute} onChange={e => onScheduleChange("minute", e.target.value)} />
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{[0, 15, 30, 45].map((m) => (
|
{[0, 15, 30, 45].map((m) => (
|
||||||
<button
|
<button
|
||||||
@@ -213,26 +231,25 @@ export function BackupsSettings({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={90}
|
max={365}
|
||||||
className="font-mono w-24"
|
className="font-mono w-24"
|
||||||
|
aria-label="Хранить копий"
|
||||||
value={schedule.keepCount}
|
value={schedule.keepCount}
|
||||||
onChange={(e) => onScheduleChange("keepCount", Math.max(1, Number(e.target.value)))}
|
onChange={(e) => onScheduleChange("keepCount", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-3">
|
<FormField label="Формат файла" hint="Плановый binary .backup пока недоступен">
|
||||||
<div>
|
<select aria-label="Формат файла" className="h-9 rounded-md border px-3 bg-background" value={schedule.format} onChange={e => onScheduleChange("format", e.target.value as "rsc" | "backup")}>
|
||||||
<p className="text-sm font-medium">Формат файла</p>
|
<option value="rsc">Текстовый экспорт .rsc</option>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Снимается текстовый экспорт RouterOS</p>
|
<option value="backup" disabled>Binary .backup — недоступен</option>
|
||||||
</div>
|
</select>
|
||||||
<div className="flex items-center gap-2">
|
</FormField>
|
||||||
<Badge variant="secondary" size="sm">.rsc</Badge>
|
|
||||||
<Badge variant="warning-light" size="sm">.backup скоро</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
</fieldset>}
|
||||||
|
|
||||||
|
{!storageKnown ? <p role="status">Хранилище пока неизвестно</p> : <fieldset disabled={!storageWritable || saveBusy || testBusy || syncBusy} className="min-w-0">
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Хранилище"
|
title="Хранилище"
|
||||||
description="Локальный диск приложения или S3-compatible бакет"
|
description="Локальный диск приложения или S3-compatible бакет"
|
||||||
@@ -353,7 +370,9 @@ export function BackupsSettings({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
</fieldset>}
|
||||||
|
|
||||||
|
<fieldset disabled={!scheduleKnown || !scheduleWritable || saveBusy || testBusy || syncBusy} className="min-w-0 lg:col-span-2">
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
title="Серверы для бэкапа"
|
title="Серверы для бэкапа"
|
||||||
@@ -374,6 +393,7 @@ export function BackupsSettings({
|
|||||||
}
|
}
|
||||||
contentClassName="px-5 py-5 flex flex-col gap-4"
|
contentClassName="px-5 py-5 flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-2 text-sm"><Checkbox aria-label="Все включённые серверы" checked={allEnabled} onCheckedChange={value => onAllEnabledChange(value === true)} /><span>Все включённые серверы, включая добавленные позже</span></div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||||
{servers.map((s) => {
|
{servers.map((s) => {
|
||||||
const checked = selectedServers.has(s.id)
|
const checked = selectedServers.has(s.id)
|
||||||
@@ -388,6 +408,7 @@ export function BackupsSettings({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
disabled={allEnabled}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={() => onToggleServer(s.id)}
|
onCheckedChange={() => onToggleServer(s.id)}
|
||||||
aria-label={`Выбрать ${s.name}`}
|
aria-label={`Выбрать ${s.name}`}
|
||||||
@@ -407,12 +428,14 @@ export function BackupsSettings({
|
|||||||
Выбрано {selectedServers.size} из {servers.length} серверов
|
Выбрано {selectedServers.size} из {servers.length} серверов
|
||||||
</p>
|
</p>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div className="lg:col-span-2 flex items-center gap-3">
|
<div className="lg:col-span-2 flex flex-wrap items-center gap-3">
|
||||||
<Button type="button" onClick={onSave} className="gap-2" disabled={saveBusy}>
|
<Button type="button" onClick={onSave} className="gap-2" disabled={!scheduleKnown || !scheduleWritable || saveBusy || testBusy || syncBusy}>
|
||||||
{saveBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
{saveBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||||
Сохранить настройки
|
Сохранить расписание
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="button" onClick={onSaveStorage} disabled={!storageKnown || !storageWritable || saveBusy || testBusy || syncBusy}>Сохранить хранилище</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
|
|||||||
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
|
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
|
||||||
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
|
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
|
||||||
|
|
||||||
Актуальная точка продолжения: 11А.27 — достоверные формы расписания/хранилища резервных копий, затем создание/checkpoint/polling и остальные операции. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты.
|
Актуальная точка продолжения: 11А.27.2 — валидация и подтверждение S3 настроек/теста, затем создание/checkpoint/polling и остальные операции. Расписание и independent save реализованы в 11А.27.1. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты.
|
||||||
+10
-1
@@ -27,7 +27,7 @@
|
|||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.26; пользователи/контейнеры/сертификаты приняты локально, каталоги/API/ручной runner резервных копий исправлены; далее 11А.27 |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.27.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: каталоги/API/runner/расписание; далее 11А.27.2 |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11А.27.1 — независимое сохранение настроек и расписания (2026-10-01)
|
||||||
|
|
||||||
|
- Расписание и хранилище имеют отдельные кнопки/PUT/подтверждения. Отказ одного DTO не скрывает известную соседнюю форму. Общий settings lock защищает save/test/sync от повторов; поля заблокированы на время отправки, старый GET отменяется. Unmount отменяет мутацию и подавляет поздние toast/state.
|
||||||
|
- Числовой черновик сохраняет blank/дробный/выходящий за диапазон ввод: значения не clamp/parseInt. Валидация 0–23/0–59/1–28/1–365 до HTTP. Доступны произвольные минуты. Точный формат сохраняется; enabled binary backup требует явного выбора поддерживаемого .rsc. Недоступность .backup не заменяется обещанием «скоро».
|
||||||
|
- Пустой список serverIds обозначает явный режим всех включённых устройств, включая будущие; пустой ручной выбор не отправляется. Устаревшие выбранные IDs требуют изменения/обновления. Backend семантика расписания не изменена.
|
||||||
|
- API подтверждает отправленные поля и наличие нового секретного ключа. Ошибка остаётся в постоянном Alert/черновике; только подтверждённый save очищает secret. Test/sync S3 используют сохранённую конфигурацию и не делают скрытый PUT; dirty форма просит явно сохранить её.
|
||||||
|
- 10 helper/API unit, 12 data + 9 settings E2E (повтор после исправления a11y имени checkbox), 8 context E2E, полный TypeScript/профильный ESLint и production frontend прошли. Реальные PostgreSQL/RouterOS/S3 не проверены.
|
||||||
|
- **Следующий блок 11А.27.2:** валидация S3/доступные имена полей, корректная свежесть последнего теста при изменении конфигурации, секрет/невалидный ack/API error/test без PUT и профильная проверка. Затем создание/checkpoint/polling, остальные операции и визуальная приёмка.
|
||||||
|
|
||||||
## Блок 11А.26 — результат операций отделён от событий (2026-10-01)
|
## Блок 11А.26 — результат операций отделён от событий (2026-10-01)
|
||||||
|
|
||||||
- Ручной job и restore используют безопасную запись событий: отказ журнала не прерывает запуск, не превращает созданные файлы в failed и не возвращает ошибку после подтверждённого импорта RouterOS. Process job вынесен в тестируемую функцию; map задач локален экземпляру routes.
|
- Ручной job и restore используют безопасную запись событий: отказ журнала не прерывает запуск, не превращает созданные файлы в failed и не возвращает ошибку после подтверждённого импорта RouterOS. Process job вынесен в тестируемую функцию; map задач локален экземпляру routes.
|
||||||
|
|||||||
@@ -328,3 +328,12 @@
|
|||||||
- Персональные ошибки остаются partial done с created/failures. Ошибка S3 загрузки сохраняет локальную копию и uploadError; итоговое событие имеет warning и число ошибок загрузки. Frontend polling пока требует отдельной доработки, чтобы не называть такой done полным успехом.
|
- Персональные ошибки остаются partial done с created/failures. Ошибка S3 загрузки сохраняет локальную копию и uploadError; итоговое событие имеет warning и число ошибок загрузки. Frontend polling пока требует отдельной доработки, чтобы не называть такой done полным успехом.
|
||||||
- 3 fault unit job, HTTP handler проверки create/restore при отказах событий, invalid target/no side effect/partial/RBAC, test:backups (включая S3 client), backend production build, 6 frontend API unit, TypeScript/профильный lint прошли. Реальный PostgreSQL/RouterOS/S3 не проверен; jobs in-memory теряются после рестарта, requestId/idempotency ещё не реализованы.
|
- 3 fault unit job, HTTP handler проверки create/restore при отказах событий, invalid target/no side effect/partial/RBAC, test:backups (включая S3 client), backend production build, 6 frontend API unit, TypeScript/профильный lint прошли. Реальный PostgreSQL/RouterOS/S3 не проверен; jobs in-memory теряются после рестарта, requestId/idempotency ещё не реализованы.
|
||||||
- **Следующий блок 11А.27:** достоверные формы расписания/хранилища, independent known состояния, dirty/секреты/partial save/общий mutation lock и S3 test без скрытого сохранения. Далее 11А.28 создание/checkpoint/polling; scheduler event/setup cleanup дополнительно проверить в 11А.26.1 или 11Б.
|
- **Следующий блок 11А.27:** достоверные формы расписания/хранилища, independent known состояния, dirty/секреты/partial save/общий mutation lock и S3 test без скрытого сохранения. Далее 11А.28 создание/checkpoint/polling; scheduler event/setup cleanup дополнительно проверить в 11А.26.1 или 11Б.
|
||||||
|
|
||||||
|
## Блок 11А.27.1 — независимое сохранение настроек и расписания (2026-10-01)
|
||||||
|
|
||||||
|
- Расписание и хранилище имеют отдельные кнопки/PUT/подтверждения. Отказ одного DTO не скрывает известную соседнюю форму. Общий settings lock защищает save/test/sync от повторов; поля заблокированы на время отправки, старый GET отменяется. Unmount отменяет мутацию и подавляет поздние toast/state.
|
||||||
|
- Числовой черновик сохраняет blank/дробный/выходящий за диапазон ввод: значения не clamp/parseInt. Валидация 0–23/0–59/1–28/1–365 до HTTP. Доступны произвольные минуты. Точный формат сохраняется; enabled binary backup требует явного выбора поддерживаемого .rsc. Недоступность .backup не заменяется обещанием «скоро».
|
||||||
|
- Пустой список serverIds обозначает явный режим всех включённых устройств, включая будущие; пустой ручной выбор не отправляется. Устаревшие выбранные IDs требуют изменения/обновления. Backend семантика расписания не изменена.
|
||||||
|
- API подтверждает отправленные поля и наличие нового секретного ключа. Ошибка остаётся в постоянном Alert/черновике; только подтверждённый save очищает secret. Test/sync S3 используют сохранённую конфигурацию и не делают скрытый PUT; dirty форма просит явно сохранить её.
|
||||||
|
- 10 helper/API unit, 12 data + 9 settings E2E (повтор после исправления a11y имени checkbox), 8 context E2E, полный TypeScript/профильный ESLint и production frontend прошли. Реальные PostgreSQL/RouterOS/S3 не проверены.
|
||||||
|
- **Следующий блок 11А.27.2:** валидация S3/доступные имена полей, корректная свежесть последнего теста при изменении конфигурации, секрет/невалидный ack/API error/test без PUT и профильная проверка. Затем создание/checkpoint/polling, остальные операции и визуальная приёмка.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { test } from "node:test"
|
||||||
|
import { backupSchedulePayload } from "./backup-settings"
|
||||||
|
const form = { enabled: false, frequency: "daily" as const, hour: "0", minute: "7", weekDay: 0, monthDay: 1, keepCount: "365", format: "rsc" as const }
|
||||||
|
test("schedule preserves zero, arbitrary minute and explicit all-enabled semantics", () => {
|
||||||
|
const result = backupSchedulePayload(form, ["1"], true)
|
||||||
|
assert.equal(result.hour, 0); assert.equal(result.minute, 7); assert.equal(result.keepCount, 365); assert.deepEqual(result.serverIds, [])
|
||||||
|
assert.deepEqual(backupSchedulePayload(form, ["1"], false).serverIds, ["1"])
|
||||||
|
})
|
||||||
|
test("blank, fractional, negative and out-of-range numbers are rejected without normalization", () => {
|
||||||
|
for (const hour of ["", "1.5", "-1", "24", "1e1"]) assert.throws(() => backupSchedulePayload({ ...form, hour }, ["1"], false))
|
||||||
|
assert.throws(() => backupSchedulePayload({ ...form, keepCount: "366" }, ["1"], false))
|
||||||
|
assert.throws(() => backupSchedulePayload(form, [], false))
|
||||||
|
})
|
||||||
|
test("unsupported binary schedule requires an explicit format choice", () => {
|
||||||
|
assert.throws(() => backupSchedulePayload({ ...form, enabled: true, format: "backup" }, ["1"], false))
|
||||||
|
assert.equal(backupSchedulePayload({ ...form, format: "backup" }, ["1"], false).format, "backup")
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { putBackupScheduleSettingsSchema, putBackupStorageSettingsSchema } from "@mmapp/contracts/backups"
|
||||||
|
import type { BackupScheduleForm, BackupStorageForm } from "@/components/backups/backups-settings"
|
||||||
|
|
||||||
|
function integer(value: number | string, label: string, min: number, max: number): number {
|
||||||
|
if (!/^[0-9]+$/.test(String(value))) throw new Error(label + ": введите целое число")
|
||||||
|
const number = Number(value)
|
||||||
|
if (!Number.isSafeInteger(number) || number < min || number > max) throw new Error(label + ": допустимо от " + min + " до " + max)
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
export function backupSchedulePayload(form: BackupScheduleForm, serverIds: string[], allEnabled: boolean) {
|
||||||
|
if (!allEnabled && !serverIds.length) throw new Error("Выберите серверы или режим всех включённых устройств")
|
||||||
|
if (form.enabled && form.format !== "rsc") throw new Error("Плановый binary backup пока не поддерживается; выберите .rsc явно")
|
||||||
|
return putBackupScheduleSettingsSchema.parse({ ...form, hour: integer(form.hour, "Час", 0, 23), minute: integer(form.minute, "Минута", 0, 59), weekDay: integer(form.weekDay, "День недели", 0, 6), monthDay: integer(form.monthDay, "День месяца", 1, 28), keepCount: integer(form.keepCount, "Число копий", 1, 365), serverIds: allEnabled ? [] : serverIds })
|
||||||
|
}
|
||||||
|
export function backupStoragePayload(form: BackupStorageForm) {
|
||||||
|
return putBackupStorageSettingsSchema.parse({ provider: form.provider, s3Endpoint: form.s3Endpoint, s3Region: form.s3Region, s3Bucket: form.s3Bucket, s3Prefix: form.s3Prefix, s3AccessKeyId: form.s3AccessKeyId, s3SecretAccessKey: form.s3SecretAccessKey || undefined, s3ForcePathStyle: form.s3ForcePathStyle, keepLocalCopy: form.keepLocalCopy })
|
||||||
|
}
|
||||||
@@ -81,3 +81,12 @@ test("restore, S3 test and sync do not accept empty or malformed success", async
|
|||||||
globalThis.fetch = async () => json({ imported: -1, skipped: 0 }); await assert.rejects(() => syncBackupsFromStorage(base))
|
globalThis.fetch = async () => json({ imported: -1, skipped: 0 }); await assert.rejects(() => syncBackupsFromStorage(base))
|
||||||
} finally { globalThis.fetch = original }
|
} finally { globalThis.fetch = original }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("settings acknowledgment must match submitted fields and confirm a new secret", async () => {
|
||||||
|
const original = globalThis.fetch
|
||||||
|
try {
|
||||||
|
globalThis.fetch = async () => json(schedule); await assert.rejects(() => putBackupScheduleSettings(base, { hour: 7 }), /не подтверждено/)
|
||||||
|
globalThis.fetch = async () => json(storage); await assert.rejects(() => putBackupStorageSettings(base, { provider: "s3" }), /не подтверждено/)
|
||||||
|
await assert.rejects(() => putBackupStorageSettings(base, { s3SecretAccessKey: "new-test-secret" }), /ключа/)
|
||||||
|
} finally { globalThis.fetch = original }
|
||||||
|
})
|
||||||
+14
-2
@@ -46,16 +46,28 @@ export async function restoreBackup(baseUrl: string, id: string, options?: Reque
|
|||||||
export async function getBackupScheduleSettings(baseUrl: string, options?: RequestInit) {
|
export async function getBackupScheduleSettings(baseUrl: string, options?: RequestInit) {
|
||||||
return backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "GET" }))
|
return backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "GET" }))
|
||||||
}
|
}
|
||||||
|
function confirmSettings(result: Record<string, unknown>, payload: Record<string, unknown>) {
|
||||||
|
for (const [key, value] of Object.entries(payload)) {
|
||||||
|
if (value === undefined) continue
|
||||||
|
if (key === "s3SecretAccessKey") {
|
||||||
|
if (value && result.secretConfigured !== true) throw new Error("Сохранение ключа хранилища не подтверждено")
|
||||||
|
} else if (JSON.stringify(result[key]) !== JSON.stringify(value)) throw new Error("Сохранение настроек не подтверждено: ответ отличается от отправленных данных")
|
||||||
|
}
|
||||||
|
}
|
||||||
export async function putBackupScheduleSettings(baseUrl: string, payload: Partial<BackupScheduleSettingsDto>, options?: RequestInit) {
|
export async function putBackupScheduleSettings(baseUrl: string, payload: Partial<BackupScheduleSettingsDto>, options?: RequestInit) {
|
||||||
const body = putBackupScheduleSettingsSchema.parse(payload)
|
const body = putBackupScheduleSettingsSchema.parse(payload)
|
||||||
return backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "PUT", body: JSON.stringify(body) }))
|
const result = backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "PUT", body: JSON.stringify(body) }))
|
||||||
|
confirmSettings(result, body)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
export async function getBackupStorageSettings(baseUrl: string, options?: RequestInit) {
|
export async function getBackupStorageSettings(baseUrl: string, options?: RequestInit) {
|
||||||
return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "GET" }))
|
return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "GET" }))
|
||||||
}
|
}
|
||||||
export async function putBackupStorageSettings(baseUrl: string, payload: PutBackupStorageSettings, options?: RequestInit) {
|
export async function putBackupStorageSettings(baseUrl: string, payload: PutBackupStorageSettings, options?: RequestInit) {
|
||||||
const body = putBackupStorageSettingsSchema.parse(payload)
|
const body = putBackupStorageSettingsSchema.parse(payload)
|
||||||
return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "PUT", body: JSON.stringify(body) }))
|
const result = backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "PUT", body: JSON.stringify(body) }))
|
||||||
|
confirmSettings(result, body)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
export async function testBackupStorage(baseUrl: string, options?: RequestInit) {
|
export async function testBackupStorage(baseUrl: string, options?: RequestInit) {
|
||||||
return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage/test", { ...options, method: "POST" }))
|
return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage/test", { ...options, method: "POST" }))
|
||||||
|
|||||||
@@ -18,3 +18,21 @@ for (const change of ["backend", "mode"]) for (const fails of [false, true]) tes
|
|||||||
await expect(page.getByText("СТАРЫЙ БЭКАП.rsc", { exact: true })).toHaveCount(0)
|
await expect(page.getByText("СТАРЫЙ БЭКАП.rsc", { exact: true })).toHaveCount(0)
|
||||||
await expect(page.getByText(/СТАРАЯ ОШИБКА БЭКАПА/)).toHaveCount(0)
|
await expect(page.getByText(/СТАРАЯ ОШИБКА БЭКАПА/)).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const kind of ["schedule", "storage"]) for (const fails of [false, true]) test("Бэкапы: позднее сохранение/" + kind + "/" + fails, async ({ page }) => {
|
||||||
|
await setupBackups(page, headers)
|
||||||
|
let started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
|
||||||
|
await page.route("**/api/backups/" + kind, async r => {
|
||||||
|
if (r.request().method() !== "PUT") return r.fallback()
|
||||||
|
started = true; await gate
|
||||||
|
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРОЕ СОХРАНЕНИЕ БЭКАПА" } } : { headers, json: { ...r.request().postDataJSON(), secretConfigured: false, serverIds: ["1"] } }) } catch {} finally { completed = true }
|
||||||
|
})
|
||||||
|
await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click()
|
||||||
|
const label = kind === "schedule" ? "Сохранить расписание" : "Сохранить хранилище"
|
||||||
|
await page.getByRole("button", { name: label, exact: true }).click(); await expect.poll(() => started).toBe(true)
|
||||||
|
await page.goto("/settings"); const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter")
|
||||||
|
await page.goto("/backups"); release(); await expect.poll(() => completed).toBe(true)
|
||||||
|
await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText(/СТАРОЕ СОХРАНЕНИЕ БЭКАПА/)).toHaveCount(0)
|
||||||
|
await expect(page.getByText(kind === "schedule" ? "Расписание сохранено" : "Хранилище сохранено", { exact: true })).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -45,7 +45,7 @@ for (const serverRead of [false, true]) test("Бэкапы: read-only и server
|
|||||||
await expect(page.getByRole("button", { name: "Новый бэкап", exact: true })).toBeDisabled()
|
await expect(page.getByRole("button", { name: "Новый бэкап", exact: true })).toBeDisabled()
|
||||||
await expect(page.getByRole("button", { name: "Удалить " + backup.filename, exact: true })).toBeDisabled()
|
await expect(page.getByRole("button", { name: "Удалить " + backup.filename, exact: true })).toBeDisabled()
|
||||||
await page.getByRole("tab", { name: "Настройки", exact: true }).click()
|
await page.getByRole("tab", { name: "Настройки", exact: true }).click()
|
||||||
await expect(page.getByRole("button", { name: "Сохранить настройки", exact: true })).toBeDisabled()
|
await expect(page.getByRole("button", { name: "Сохранить расписание", exact: true })).toBeDisabled()
|
||||||
if (!serverRead) expect(serverRequests).toBe(0)
|
if (!serverRead) expect(serverRequests).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test"
|
||||||
|
import { setupBackups, schedule, storage } from "./fixtures/backups"
|
||||||
|
async function open(page: Page) { await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click(); await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toBeVisible() }
|
||||||
|
const save = (page: Page) => page.getByRole("button", { name: "Сохранить расписание", exact: true })
|
||||||
|
test("Бэкапы: save расписания не сохраняет хранилище", async ({ page }) => {
|
||||||
|
await setupBackups(page); let puts = 0, storagePuts = 0, body: Record<string, unknown> = {}
|
||||||
|
await page.route("**/api/backups/storage", r => { if (r.request().method() === "PUT") storagePuts++; return r.fallback() })
|
||||||
|
await page.route("**/api/backups/schedule", r => { if (r.request().method() !== "PUT") return r.fallback(); puts++; body = r.request().postDataJSON(); return r.fulfill({ json: { ...schedule, ...body } }) })
|
||||||
|
await open(page); await page.getByRole("spinbutton", { name: "Час запуска" }).fill("7"); await page.getByRole("spinbutton", { name: "Минута запуска" }).fill("7")
|
||||||
|
await save(page).click(); await expect(page.getByText("Расписание сохранено", { exact: true })).toBeVisible()
|
||||||
|
expect(puts).toBe(1); expect(storagePuts).toBe(0); expect(body.hour).toBe(7); expect(body.minute).toBe(7); expect(body.format).toBe("rsc")
|
||||||
|
})
|
||||||
|
for (const failure of ["API", "mismatch"]) test("Бэкапы: ошибка " + failure + " сохраняет расписание", async ({ page }) => {
|
||||||
|
await setupBackups(page)
|
||||||
|
await page.route("**/api/backups/schedule", r => r.request().method() !== "PUT" ? r.fallback() : r.fulfill(failure === "API" ? { status: 503, json: { error: "Расписание отказало" } } : { json: schedule }))
|
||||||
|
await open(page); await page.getByRole("spinbutton", { name: "Час запуска" }).fill("7"); await save(page).click()
|
||||||
|
await expect(page.getByText("Расписание сохранено", { exact: true })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toHaveValue("7")
|
||||||
|
await expect(save(page)).toBeEnabled()
|
||||||
|
})
|
||||||
|
test("Бэкапы: неверные числа и пустой явный выбор не отправляются", async ({ page }) => {
|
||||||
|
await setupBackups(page); let puts = 0
|
||||||
|
await page.route("**/api/backups/schedule", r => { if (r.request().method() === "PUT") puts++; return r.fallback() })
|
||||||
|
await open(page)
|
||||||
|
for (const value of ["", "1.5", "24"]) { await page.getByRole("spinbutton", { name: "Час запуска" }).fill(value); await save(page).click(); await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toHaveValue(value) }
|
||||||
|
await page.getByRole("spinbutton", { name: "Час запуска" }).fill("0")
|
||||||
|
await page.getByRole("button", { name: "Сбросить", exact: true }).click(); await save(page).click(); expect(puts).toBe(0)
|
||||||
|
})
|
||||||
|
test("Бэкапы: все включённые устройства передаются явно как пустой список", async ({ page }) => {
|
||||||
|
await setupBackups(page); let body: Record<string, unknown> = {}
|
||||||
|
await page.route("**/api/backups/schedule", r => { if (r.request().method() !== "PUT") return r.fallback(); body = r.request().postDataJSON(); return r.fulfill({ json: { ...schedule, ...body } }) })
|
||||||
|
await open(page); await page.getByRole("checkbox", { name: "Все включённые серверы", exact: true }).check(); await save(page).click()
|
||||||
|
await expect(page.getByText("Расписание сохранено", { exact: true })).toBeVisible(); expect(body.serverIds).toEqual([])
|
||||||
|
})
|
||||||
|
test("Бэкапы: общий lock не допускает второй PUT и блокирует поля", async ({ page }) => {
|
||||||
|
await setupBackups(page); let puts = 0, release!: () => void; const gate = new Promise<void>(r => release = r)
|
||||||
|
await page.route("**/api/backups/schedule", async r => { if (r.request().method() !== "PUT") return r.fallback(); puts++; await gate; await r.fulfill({ json: { ...schedule, ...r.request().postDataJSON() } }).catch(() => {}) })
|
||||||
|
await open(page); await save(page).dblclick()
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toBeDisabled()
|
||||||
|
await expect(page.getByRole("button", { name: "Сохранить хранилище", exact: true })).toBeDisabled()
|
||||||
|
release(); await expect(save(page)).toBeEnabled(); expect(puts).toBe(1)
|
||||||
|
})
|
||||||
|
test("Бэкапы: отказ расписания не скрывает форму хранилища", async ({ page }) => {
|
||||||
|
await setupBackups(page); await page.route("**/api/backups/schedule", r => r.fulfill({ status: 503, json: { error: "Schedule unavailable" } }))
|
||||||
|
await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click()
|
||||||
|
await expect(page.getByRole("button", { name: "Сохранить хранилище", exact: true })).toBeEnabled()
|
||||||
|
await expect(save(page)).toBeDisabled(); await expect(page.getByRole("spinbutton", { name: "Час запуска" })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
test("Бэкапы: проверка S3 не сохраняет черновик незаметно", async ({ page }) => {
|
||||||
|
await setupBackups(page); let mutations = 0
|
||||||
|
await page.route("**/api/backups/storage**", r => { if (r.request().method() !== "GET") mutations++; return r.fallback() })
|
||||||
|
await open(page); await page.getByRole("button", { name: "S3", exact: true }).click()
|
||||||
|
await page.getByPlaceholder("mikrotik-backups", { exact: true }).fill("draft-bucket")
|
||||||
|
await page.getByRole("button", { name: "Проверить", exact: true }).click()
|
||||||
|
await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("Сначала сохраните хранилище; проверка использует подтверждённые настройки")
|
||||||
|
expect(mutations).toBe(0)
|
||||||
|
await expect(page.getByPlaceholder("mikrotik-backups", { exact: true })).toHaveValue("draft-bucket")
|
||||||
|
})
|
||||||
|
test("Бэкапы: независимое подтверждение хранилища не отправляет расписание", async ({ page }) => {
|
||||||
|
await setupBackups(page); let schedulePuts = 0, storagePuts = 0
|
||||||
|
await page.route("**/api/backups/schedule", r => { if (r.request().method() === "PUT") schedulePuts++; return r.fallback() })
|
||||||
|
await page.route("**/api/backups/storage", r => { if (r.request().method() !== "PUT") return r.fallback(); storagePuts++; return r.fulfill({ json: { ...storage, ...r.request().postDataJSON() } }) })
|
||||||
|
await open(page); await page.getByRole("button", { name: "Сохранить хранилище", exact: true }).click()
|
||||||
|
await expect(page.getByText("Хранилище сохранено", { exact: true })).toBeVisible(); expect(storagePuts).toBe(1); expect(schedulePuts).toBe(0)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user