fix(backups): продолжать наблюдение без повторного создания
This commit is contained in:
1 parent
4aee2a34f1
commit
714af7e872
9 files changed
+265
-74
No files matched your search
+32
-66
@@ -23,9 +23,7 @@ import { can } from "@/lib/auth"
|
|||||||
import { userApiError } from "@/shared/api/users"
|
import { userApiError } from "@/shared/api/users"
|
||||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
import {
|
import {
|
||||||
createBackupsAsync,
|
|
||||||
deleteBackup,
|
deleteBackup,
|
||||||
getBackupJob,
|
|
||||||
getBackupScheduleSettings,
|
getBackupScheduleSettings,
|
||||||
getBackupStorageSettings,
|
getBackupStorageSettings,
|
||||||
listBackups,
|
listBackups,
|
||||||
@@ -38,6 +36,8 @@ import {
|
|||||||
} from "@/shared/api/backups"
|
} from "@/shared/api/backups"
|
||||||
import { ApiClientError, requestJson, requestBlob } from "@/shared/api/http-client"
|
import { ApiClientError, requestJson, requestBlob } from "@/shared/api/http-client"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { useBackupJob } from "@/lib/use-backup-job"
|
||||||
|
import { backupJobOutcome } from "@/lib/backup-job-poll"
|
||||||
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
|
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
|
||||||
import { BackupsHistory } from "@/components/backups/backups-history"
|
import { BackupsHistory } from "@/components/backups/backups-history"
|
||||||
import {
|
import {
|
||||||
@@ -85,11 +85,10 @@ function BackupsContent() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [advancedFilters, setAdvancedFilters] = useState<FilterQuery>(() => createFilterQuery())
|
const [advancedFilters, setAdvancedFilters] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers)
|
const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers)
|
||||||
const canCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
|
const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [opBusy, setOpBusy] = useState(false)
|
const [opBusy, setOpBusy] = useState(false)
|
||||||
const [opError, setOpError] = useState<string | null>(null)
|
const [opError, setOpError] = useState<string | null>(null)
|
||||||
const [backupJobId, setBackupJobId] = useState<string | null>(null)
|
|
||||||
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
||||||
const [testBusy, setTestBusy] = useState(false)
|
const [testBusy, setTestBusy] = useState(false)
|
||||||
const [syncBusy, setSyncBusy] = useState(false)
|
const [syncBusy, setSyncBusy] = useState(false)
|
||||||
@@ -165,6 +164,12 @@ function BackupsContent() {
|
|||||||
return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort() }
|
return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort() }
|
||||||
}, [loadLive])
|
}, [loadLive])
|
||||||
|
|
||||||
|
const job = useBackupJob(backendUrl, isLive, canRead, baseCanCreate && !opBusy && !scheduleSaveBusy, loadLive)
|
||||||
|
const checkpoint = job.checkpoint
|
||||||
|
const jobTerminal = !!checkpoint?.job && ["done", "failed"].includes(checkpoint.job.status)
|
||||||
|
const backupJobId = checkpoint && !jobTerminal ? checkpoint.request.requestId : null
|
||||||
|
const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError
|
||||||
|
|
||||||
function startSettingsMutation() {
|
function startSettingsMutation() {
|
||||||
if (!alive.current || settingsLock.current || opBusy || backupJobId) return null
|
if (!alive.current || settingsLock.current || opBusy || backupJobId) return null
|
||||||
settingsLock.current = true
|
settingsLock.current = true
|
||||||
@@ -246,60 +251,12 @@ function BackupsContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleManualBackup() {
|
async function handleManualBackup() {
|
||||||
if (!canCreate || manualServers.size === 0 || opBusy) return
|
if (checkpoint) { await job.run(); return }
|
||||||
setOpError(null)
|
if (!canCreate || !manualServers.size || settingsLock.current || opBusy) return
|
||||||
try {
|
if ([...manualServers].some(id => !liveServers.some(server => server.id === id))) { setOpError("Выбранный сервер отсутствует; обновите выбор"); return }
|
||||||
const res = await createBackupsAsync(backendUrl, {
|
await job.run({ serverIds: [...manualServers], notes: manualNotes || undefined })
|
||||||
serverIds: [...manualServers],
|
|
||||||
notes: manualNotes || undefined,
|
|
||||||
})
|
|
||||||
setBackupJobId(res.jobId)
|
|
||||||
setManualOpen(false)
|
|
||||||
setManualServers(new Set())
|
|
||||||
setManualNotes("")
|
|
||||||
setTab("history")
|
|
||||||
} catch (e) {
|
|
||||||
setOpError(e instanceof Error ? e.message : "Ошибка создания бэкапа")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!backupJobId) return
|
|
||||||
toast.info("Бэкап выполняется в фоне...")
|
|
||||||
let cancelled = false
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const job = await getBackupJob(backendUrl, backupJobId)
|
|
||||||
if (cancelled) return
|
|
||||||
if (job.status === "done") {
|
|
||||||
clearInterval(timer)
|
|
||||||
setBackupJobId(null)
|
|
||||||
if (job.failures.length > 0) {
|
|
||||||
setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`)
|
|
||||||
} else {
|
|
||||||
toast.success("Бэкап успешно завершён")
|
|
||||||
}
|
|
||||||
await loadLive()
|
|
||||||
} else if (job.status === "failed") {
|
|
||||||
clearInterval(timer)
|
|
||||||
setBackupJobId(null)
|
|
||||||
setOpError(job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ") || "Ошибка фоновой задачи бэкапа")
|
|
||||||
await loadLive()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
clearInterval(timer)
|
|
||||||
setBackupJobId(null)
|
|
||||||
setOpError(e instanceof Error ? e.message : "Ошибка опроса задачи бэкапа")
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, 1200)
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
clearInterval(timer)
|
|
||||||
}
|
|
||||||
}, [backendUrl, backupJobId, loadLive])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (opError) toast.error(opError)
|
if (opError) toast.error(opError)
|
||||||
}, [opError])
|
}, [opError])
|
||||||
@@ -377,6 +334,7 @@ function BackupsContent() {
|
|||||||
: "default"
|
: "default"
|
||||||
|
|
||||||
function openCreate(all: boolean) {
|
function openCreate(all: boolean) {
|
||||||
|
if (checkpoint) { setManualStep(3); setManualOpen(true); return }
|
||||||
if (!canCreate) return
|
if (!canCreate) return
|
||||||
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
|
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
|
||||||
setManualNotes("")
|
setManualNotes("")
|
||||||
@@ -395,7 +353,7 @@ function BackupsContent() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openCreate(true)}
|
onClick={() => openCreate(true)}
|
||||||
disabled={!canCreate || loading}
|
disabled={!canCreate || loading || job.busy}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-4" />
|
<RefreshCwIcon className="size-4" />
|
||||||
Снять со всех
|
Снять со всех
|
||||||
@@ -404,10 +362,10 @@ function BackupsContent() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openCreate(false)}
|
onClick={() => openCreate(false)}
|
||||||
disabled={!canCreate || loading}
|
disabled={checkpoint ? !canRead : !canCreate || loading || job.busy}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Новый бэкап
|
{checkpoint ? "Задача резервного копирования" : "Новый бэкап"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -428,6 +386,7 @@ function BackupsContent() {
|
|||||||
</Alert>)}
|
</Alert>)}
|
||||||
|
|
||||||
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
|
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
|
||||||
|
{(job.error || job.storageError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError}</AlertDescription></Alert>}
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка бэкапов"
|
aria-label="Сводка бэкапов"
|
||||||
items={[
|
items={[
|
||||||
@@ -465,11 +424,11 @@ function BackupsContent() {
|
|||||||
<Alert variant={alertVariant}>
|
<Alert variant={alertVariant}>
|
||||||
<ClockIcon />
|
<ClockIcon />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
{backupJobId ? "Задача выполняется" : schedSummary}
|
{backupJobId ? (checkpoint?.accepted ? "Задача наблюдается" : "Результат запроса пока неизвестен") : schedSummary}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{backupJobId
|
{backupJobId
|
||||||
? "Снимок конфигурации снимается в фоне. Список обновится после завершения."
|
? "Закрытие формы не отменяет задачу. Проверка продолжает чтение того же ID."
|
||||||
: statusDetail}
|
: statusDetail}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
<AlertAction>
|
<AlertAction>
|
||||||
@@ -532,8 +491,8 @@ function BackupsContent() {
|
|||||||
scheduleKnown={known("schedule")}
|
scheduleKnown={known("schedule")}
|
||||||
storageKnown={known("storage")}
|
storageKnown={known("storage")}
|
||||||
storageDirty={storageIsDirty}
|
storageDirty={storageIsDirty}
|
||||||
scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers")}
|
scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers") && !backupJobId}
|
||||||
storageWritable={canWrite && current("storage")}
|
storageWritable={canWrite && current("storage") && !backupJobId}
|
||||||
allEnabled={allEnabled}
|
allEnabled={allEnabled}
|
||||||
onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }}
|
onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }}
|
||||||
onTest={handleTestStorage}
|
onTest={handleTestStorage}
|
||||||
@@ -554,7 +513,7 @@ function BackupsContent() {
|
|||||||
step={manualStep}
|
step={manualStep}
|
||||||
onStepChange={setManualStep}
|
onStepChange={setManualStep}
|
||||||
servers={liveServers}
|
servers={liveServers}
|
||||||
selected={manualServers}
|
selected={checkpoint ? new Set(checkpoint.request.serverIds.map(String)) : manualServers}
|
||||||
onToggle={(id) => {
|
onToggle={(id) => {
|
||||||
setManualServers((prev) => {
|
setManualServers((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -565,10 +524,17 @@ function BackupsContent() {
|
|||||||
}}
|
}}
|
||||||
onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
|
onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
|
||||||
onClear={() => setManualServers(new Set())}
|
onClear={() => setManualServers(new Set())}
|
||||||
notes={manualNotes}
|
notes={checkpoint?.request.notes ?? manualNotes}
|
||||||
onNotesChange={setManualNotes}
|
onNotesChange={setManualNotes}
|
||||||
destinationLabel={destinationLabel}
|
destinationLabel={destinationLabel}
|
||||||
busy={backupJobId !== null}
|
busy={job.busy}
|
||||||
|
locked={!!checkpoint}
|
||||||
|
terminal={jobTerminal}
|
||||||
|
canObserve={canRead}
|
||||||
|
jobId={checkpoint?.request.requestId}
|
||||||
|
jobStatus={checkpoint?.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Прервана" })[checkpoint.job.status] + ": " + checkpoint.job.completed + "/" + checkpoint.job.total : checkpoint?.accepted ? "Запрос принят, результат ещё неизвестен" : "Результат запроса ещё неизвестен"}
|
||||||
|
result={checkpoint?.job && jobTerminal ? "Создано " + backupJobOutcome(checkpoint.job).created + " из " + checkpoint.job.total : undefined}
|
||||||
|
onReset={() => { if (job.reset()) { setManualServers(new Set()); setManualNotes(""); setManualStep(1) } }}
|
||||||
onSubmit={handleManualBackup}
|
onSubmit={handleManualBackup}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ export function BackupCreateSheet({
|
|||||||
destinationLabel,
|
destinationLabel,
|
||||||
busy,
|
busy,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
locked = false,
|
||||||
|
terminal = false,
|
||||||
|
canObserve = true,
|
||||||
|
jobId,
|
||||||
|
jobStatus,
|
||||||
|
result,
|
||||||
|
onReset,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
@@ -57,16 +64,25 @@ export function BackupCreateSheet({
|
|||||||
onNotesChange: (value: string) => void
|
onNotesChange: (value: string) => void
|
||||||
destinationLabel: string
|
destinationLabel: string
|
||||||
busy: boolean
|
busy: boolean
|
||||||
|
locked?: boolean
|
||||||
|
terminal?: boolean
|
||||||
|
canObserve?: boolean
|
||||||
|
jobId?: string
|
||||||
|
jobStatus?: string
|
||||||
|
result?: string
|
||||||
|
onReset: () => void
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) onStepChange(1) }}>
|
<Sheet open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) onStepChange(1) }}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
<SheetContent side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-md min-w-0 flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Новый бэкап</SheetTitle>
|
<SheetTitle>{locked ? "Задача резервного копирования" : "Новый бэкап"}</SheetTitle>
|
||||||
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
|
{locked && <div role="status" className="px-6 py-3 text-sm break-all"><p>ID: {jobId}</p><p>{jobStatus}</p>{result && <p>{result}</p>}<p className="text-xs text-muted-foreground">Закрытие формы не отменяет задачу. Повторная проверка не создаёт новые копии.</p></div>}
|
||||||
|
<fieldset disabled={busy || locked} className="flex-1 min-h-0 flex flex-col">
|
||||||
<Stepper value={step} onValueChange={onStepChange} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
<Stepper value={step} onValueChange={onStepChange} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
<StepperNav className="mb-5">
|
<StepperNav className="mb-5">
|
||||||
<StepperItem step={1}>
|
<StepperItem step={1}>
|
||||||
@@ -141,6 +157,8 @@ export function BackupCreateSheet({
|
|||||||
<FormField label="Заметка">
|
<FormField label="Заметка">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Например: перед обновлением BGP"
|
placeholder="Например: перед обновлением BGP"
|
||||||
|
aria-label="Заметка резервной копии"
|
||||||
|
maxLength={500}
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => onNotesChange(e.target.value)}
|
onChange={(e) => onNotesChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -157,15 +175,16 @@ export function BackupCreateSheet({
|
|||||||
</StepperContent>
|
</StepperContent>
|
||||||
</StepperPanel>
|
</StepperPanel>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||||
{step > 1 && (
|
{!locked && step > 1 && (
|
||||||
<Button type="button" variant="outline" className="flex-1" onClick={() => onStepChange(step - 1)}>
|
<Button type="button" variant="outline" className="flex-1" onClick={() => onStepChange(step - 1)}>
|
||||||
Назад
|
Назад
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{step < 3 ? (
|
{locked ? <><Button type="button" disabled={!canObserve || busy || terminal} onClick={onSubmit}>Проверить задачу</Button>{terminal && <Button type="button" onClick={onReset}>Новая задача</Button>}</> : step < 3 ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
|||||||
+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А.28.2 — frontend checkpoint/создание/serial polling резервных копий и подтверждение partial результата, затем остальные операции/URL/приёмка. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/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А.29 — delete/restore/download резервных копий, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/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А.28.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: настройки/идентичность job, далее 11А.28.2 |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.28.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: настройки/создание/наблюдение, далее 11А.29 |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11А.28.2 — сохраняемая задача создания резервных копий (2026-10-01)
|
||||||
|
|
||||||
|
- До POST сохраняется immutable requestId/serverIds/заметка в sessionStorage по backend URL. Первичная ошибка записи не отправляет запрос. Форма блокирует изменение цели после передачи; закрытие не объявляется отменой server job. При подтверждённом 4xx создания черновик остаётся доступным для исправления.
|
||||||
|
- Reload/неизвестный POST восстанавливает ту же задачу. Любой resume выполняет только GET/serial polling: 404 in-memory job означает неизвестный результат/возможный рестарт, не разрешение второго POST. Некорректный checkpoint/чужая цель не допускают новое создание. New task доступен только после известного terminal и отдельного действия.
|
||||||
|
- Done с device failures/uploadError показывает подтверждённый частичный результат и созданное количество; полный success требует всех файлов без ошибок. Неуспешное перечитывание истории отдельно сохраняет подтверждение job. Общий lock формы исключает двойной POST; settings при активном/неизвестном job недоступны.
|
||||||
|
- Late POST/poll/задержки отменяются при unmount и не меняют другой backend/mode, не показывают старый toast. Sheet адаптирован full width через data-side вариант; footer переносится, цель и job ID сохраняются.
|
||||||
|
- 9 job E2E + 28 data/settings/storage регрессии, 16 context E2E, TypeScript/профильный ESLint/production frontend прошли; helper/backend проверки выполнены в 11А.28.1. Реальные PostgreSQL/RouterOS/S3/рестарт не проверены; durable job repository отсутствует.
|
||||||
|
- **Следующий блок 11А.29:** delete/restore/download: synchronous locks, контекст/отмена, точное подтверждение цели и отделение API результата от refresh; backend partial cleanup и правила восстановления проверить. Затем URL, Frame/адаптивность, scheduler cleanup и приёмка backups.
|
||||||
|
|
||||||
## Блок 11А.28.1 — идентичность задачи и основа наблюдения (2026-10-01)
|
## Блок 11А.28.1 — идентичность задачи и основа наблюдения (2026-10-01)
|
||||||
|
|
||||||
- Create request принимает необязательный UUID requestId; job/ack содержат canonical serverIds, job содержит заметку. Повтор той же цели/заметки в том же backend процессе возвращает прежний job; конфликт ID возвращает 409. Конкурентный повтор после async catalog lookup резервируется одним Map insert, без второй очереди. Старые клиенты без ID получают отдельные UUID.
|
- Create request принимает необязательный UUID requestId; job/ack содержат canonical serverIds, job содержит заметку. Повтор той же цели/заметки в том же backend процессе возвращает прежний job; конфликт ID возвращает 409. Конкурентный повтор после async catalog lookup резервируется одним Map insert, без второй очереди. Старые клиенты без ID получают отдельные UUID.
|
||||||
|
|||||||
@@ -354,3 +354,12 @@
|
|||||||
- 16 frontend API/checkpoint/poll unit, backend test:backups с HTTP concurrent retry/conflict/RBAC, backend production build, полный TypeScript/профильный ESLint прошли. UI пока использует старое создание/setInterval; подключение в следующем блоке.
|
- 16 frontend API/checkpoint/poll unit, backend test:backups с HTTP concurrent retry/conflict/RBAC, backend production build, полный TypeScript/профильный ESLint прошли. UI пока использует старое создание/setInterval; подключение в следующем блоке.
|
||||||
- Jobs/idempotency Map существуют только в текущем процессе. После рестарта API может вернуть 404 для ранее принятой операции; автоматический повтор неизвестного POST не реализован и недопустим без durable repository. Реальные DB/RouterOS/S3/рестарт не проверены.
|
- Jobs/idempotency Map существуют только в текущем процессе. После рестарта API может вернуть 404 для ранее принятой операции; автоматический повтор неизвестного POST не реализован и недопустим без durable repository. Реальные DB/RouterOS/S3/рестарт не проверены.
|
||||||
- **Следующий блок 11А.28.2:** frontend checkpoint до POST, общий lock формы, GET-only resume/после reload/неизвестного ответа, точный partial результат, поздние ответы источника и профильные E2E. Затем остальные операции/URL/визуальная приёмка.
|
- **Следующий блок 11А.28.2:** frontend checkpoint до POST, общий lock формы, GET-only resume/после reload/неизвестного ответа, точный partial результат, поздние ответы источника и профильные E2E. Затем остальные операции/URL/визуальная приёмка.
|
||||||
|
|
||||||
|
## Блок 11А.28.2 — сохраняемая задача создания резервных копий (2026-10-01)
|
||||||
|
|
||||||
|
- До POST сохраняется immutable requestId/serverIds/заметка в sessionStorage по backend URL. Первичная ошибка записи не отправляет запрос. Форма блокирует изменение цели после передачи; закрытие не объявляется отменой server job. При подтверждённом 4xx создания черновик остаётся доступным для исправления.
|
||||||
|
- Reload/неизвестный POST восстанавливает ту же задачу. Любой resume выполняет только GET/serial polling: 404 in-memory job означает неизвестный результат/возможный рестарт, не разрешение второго POST. Некорректный checkpoint/чужая цель не допускают новое создание. New task доступен только после известного terminal и отдельного действия.
|
||||||
|
- Done с device failures/uploadError показывает подтверждённый частичный результат и созданное количество; полный success требует всех файлов без ошибок. Неуспешное перечитывание истории отдельно сохраняет подтверждение job. Общий lock формы исключает двойной POST; settings при активном/неизвестном job недоступны.
|
||||||
|
- Late POST/poll/задержки отменяются при unmount и не меняют другой backend/mode, не показывают старый toast. Sheet адаптирован full width через data-side вариант; footer переносится, цель и job ID сохраняются.
|
||||||
|
- 9 job E2E + 28 data/settings/storage регрессии, 16 context E2E, TypeScript/профильный ESLint/production frontend прошли; helper/backend проверки выполнены в 11А.28.1. Реальные PostgreSQL/RouterOS/S3/рестарт не проверены; durable job repository отсутствует.
|
||||||
|
- **Следующий блок 11А.29:** delete/restore/download: synchronous locks, контекст/отмена, точное подтверждение цели и отделение API результата от refresh; backend partial cleanup и правила восстановления проверить. Затем URL, Frame/адаптивность, scheduler cleanup и приёмка backups.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import type { CreateBackupRequest } from "@mmapp/contracts/backups"
|
||||||
|
import { ApiClientError } from "@/shared/api/http-client"
|
||||||
|
import { createBackupsAsync } from "@/shared/api/backups"
|
||||||
|
import { pollBackupJob, backupJobOutcome } from "@/lib/backup-job-poll"
|
||||||
|
import { backupJobStorageKey, createBackupJobCheckpoint, readBackupJobCheckpoint, type BackupJobCheckpoint } from "@/lib/backup-job-checkpoint"
|
||||||
|
import { userApiError } from "@/shared/api/users"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export function useBackupJob(backendUrl: string, live: boolean, canRead: boolean, canCreate: boolean, onDone: () => Promise<void>) {
|
||||||
|
const [checkpoint, setCheckpoint] = useState<BackupJobCheckpoint | null>(null)
|
||||||
|
const current = useRef<BackupJobCheckpoint | null>(null)
|
||||||
|
const [hydrated, setHydrated] = useState(false), [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null), [storageError, setStorageError] = useState<string | null>(null)
|
||||||
|
const alive = useRef(true), lock = useRef(false), controller = useRef<AbortController | null>(null)
|
||||||
|
const key = backupJobStorageKey(backendUrl)
|
||||||
|
function persist(value: BackupJobCheckpoint | null, required = false) {
|
||||||
|
try { if (value) sessionStorage.setItem(key, JSON.stringify(value)); else sessionStorage.removeItem(key); setStorageError(null) }
|
||||||
|
catch { if (required) throw new Error("Не удалось сохранить задачу в этой вкладке. Запрос не отправлен"); setStorageError("Не удалось обновить запись задачи. Держите вкладку открытой до подтверждения результата") }
|
||||||
|
current.current = value; setCheckpoint(value)
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
alive.current = true
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!alive.current) return
|
||||||
|
try { const saved = live ? readBackupJobCheckpoint(sessionStorage.getItem(key)) : null; current.current = saved; setCheckpoint(saved) }
|
||||||
|
catch { setStorageError("Не удалось восстановить задачу. Новое создание заблокировано, чтобы не отправить повтор") }
|
||||||
|
setHydrated(true)
|
||||||
|
})
|
||||||
|
return () => { alive.current = false; controller.current?.abort() }
|
||||||
|
}, [key, live])
|
||||||
|
async function run(request?: CreateBackupRequest) {
|
||||||
|
if (!live || !hydrated || !canRead || lock.current || !alive.current) return
|
||||||
|
if (!current.current && (!canCreate || storageError || !request)) return
|
||||||
|
lock.current = true; setBusy(true); setError(null)
|
||||||
|
const abort = new AbortController(); controller.current = abort
|
||||||
|
const active = () => alive.current && !abort.signal.aborted
|
||||||
|
let saved = current.current, creating = false
|
||||||
|
try {
|
||||||
|
if (!saved) {
|
||||||
|
saved = createBackupJobCheckpoint(request!, crypto.randomUUID()); persist(saved, true); creating = true
|
||||||
|
await createBackupsAsync(backendUrl, { ...saved.request, serverIds: saved.request.serverIds.map(String) }, { signal: abort.signal })
|
||||||
|
if (!active()) return
|
||||||
|
saved = { ...saved, accepted: true }; persist(saved)
|
||||||
|
}
|
||||||
|
// A restored/unknown request is only observed. In-memory backend records cannot safely justify another POST after 404.
|
||||||
|
const target = saved.request
|
||||||
|
const result = await pollBackupJob(backendUrl, target.requestId, target, { signal: abort.signal, onJob: job => { if (active()) persist({ request: target, accepted: true, job }) } })
|
||||||
|
if (!active()) return
|
||||||
|
const outcome = backupJobOutcome(result)
|
||||||
|
if (outcome.fullSuccess) toast.success("API подтвердил создание всех резервных копий", { id: "backup-job-" + result.id })
|
||||||
|
else {
|
||||||
|
const failures = [...outcome.deviceFailures.map(failure => failure.serverId + ": " + failure.error), ...outcome.uploadFailures.map(file => file.filename + ": " + file.uploadError)]
|
||||||
|
setError("Создано " + outcome.created + " из " + result.total + ". " + (failures.join("; ") || "Задача завершилась с ошибкой"))
|
||||||
|
}
|
||||||
|
await onDone()
|
||||||
|
} catch (reason) {
|
||||||
|
if (!active()) return
|
||||||
|
if (creating && !saved?.accepted && reason instanceof ApiClientError && [400, 403, 404, 409].includes(reason.status)) persist(null)
|
||||||
|
const message = reason instanceof ApiClientError && reason.status === 404 && current.current ? "Задача недоступна на сервере. После рестарта сведения могли быть потеряны; проверьте историю и устройства" : userApiError(reason)
|
||||||
|
setError(message + (current.current ? ". Повторяется только чтение этой задачи; новое создание не запускается" : ""))
|
||||||
|
} finally { lock.current = false; if (active()) setBusy(false) }
|
||||||
|
}
|
||||||
|
function reset() {
|
||||||
|
if (lock.current || !checkpoint?.job || !["done", "failed"].includes(checkpoint.job.status)) return false
|
||||||
|
try { persist(null, true); setError(null); return true } catch (reason) { setError(userApiError(reason)); return false }
|
||||||
|
}
|
||||||
|
return { checkpoint, hydrated, busy, error, storageError, run, reset }
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from "@playwright/test"
|
import { test, expect } from "@playwright/test"
|
||||||
|
import { openBackup, backupJob, backupAck } from "./fixtures/backup-job"
|
||||||
import { backup, setupBackups } from "./fixtures/backups"
|
import { backup, setupBackups } from "./fixtures/backups"
|
||||||
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
|
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
|
||||||
for (const change of ["backend", "mode"]) for (const fails of [false, true]) test("Бэкапы: позднее чтение/" + change + "/" + fails, async ({ page }) => {
|
for (const change of ["backend", "mode"]) for (const fails of [false, true]) test("Бэкапы: позднее чтение/" + change + "/" + fails, async ({ page }) => {
|
||||||
@@ -36,3 +37,29 @@ for (const kind of ["schedule", "storage"]) for (const fails of [false, true]) t
|
|||||||
await expect(page.getByText(/СТАРОЕ СОХРАНЕНИЕ БЭКАПА/)).toHaveCount(0)
|
await expect(page.getByText(/СТАРОЕ СОХРАНЕНИЕ БЭКАПА/)).toHaveCount(0)
|
||||||
await expect(page.getByText(kind === "schedule" ? "Расписание сохранено" : "Хранилище сохранено", { exact: true })).toHaveCount(0)
|
await expect(page.getByText(kind === "schedule" ? "Расписание сохранено" : "Хранилище сохранено", { exact: true })).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const phase of ["POST", "poll"]) for (const change of ["backend", "mode"]) for (const fails of [false, true]) test("Бэкапы: поздний job/" + phase + "/" + change + "/" + fails, async ({ page }) => {
|
||||||
|
await setupBackups(page, headers)
|
||||||
|
let id = "", started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
|
||||||
|
await page.route("**/api/backups/create", async r => {
|
||||||
|
id = r.request().postDataJSON().requestId
|
||||||
|
if (phase !== "POST") return r.fulfill({ headers, status: 202, json: backupAck(id) })
|
||||||
|
started = true; await gate
|
||||||
|
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ JOB ОТКАЗ" } } : { headers, status: 202, json: backupAck(id) }) } catch {} finally { completed = true }
|
||||||
|
})
|
||||||
|
await page.route("**/api/backups/jobs/*", async r => {
|
||||||
|
if (phase !== "poll") return r.fulfill({ headers, json: backupJob(id) })
|
||||||
|
started = true; await gate
|
||||||
|
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ JOB ОТКАЗ" } } : { headers, json: backupJob(id) }) } catch {} finally { completed = true }
|
||||||
|
})
|
||||||
|
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click(); await expect.poll(() => started).toBe(true)
|
||||||
|
await page.goto("/settings")
|
||||||
|
if (change === "backend") { const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter") }
|
||||||
|
else await page.getByRole("button", { name: "Моковые", exact: true }).click()
|
||||||
|
await page.goto("/backups"); release(); await expect.poll(() => completed).toBe(true)
|
||||||
|
if (change === "backend") await expect(page.getByText(backup.filename, { exact: true })).toBeVisible()
|
||||||
|
else await expect(page.getByText("Демонстрационные данные. Операции и сохранение недоступны.", { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText(/СТАРЫЙ JOB ОТКАЗ/)).toHaveCount(0)
|
||||||
|
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole("button", { name: "Задача резервного копирования", exact: true })).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
import { setupBackups, backup } from "./fixtures/backups"
|
||||||
|
import { openBackup, backupJob, backupAck } from "./fixtures/backup-job"
|
||||||
|
|
||||||
|
test("Бэкапы job: single-flight POST и подтверждённый результат", async ({ page }) => {
|
||||||
|
await setupBackups(page); let posts = 0, id = "", release!: () => void; const gate = new Promise<void>(r => release = r)
|
||||||
|
await page.route("**/api/backups/create", async r => { posts++; id = r.request().postDataJSON().requestId; await gate; await r.fulfill({ status: 202, json: backupAck(id) }).catch(() => {}) })
|
||||||
|
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id) }))
|
||||||
|
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).dblclick()
|
||||||
|
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeDisabled()
|
||||||
|
release(); await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toBeVisible()
|
||||||
|
expect(posts).toBe(1); await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
test("Бэкапы job: отклонённый POST сохраняет форму для исправления", async ({ page }) => {
|
||||||
|
await setupBackups(page); await page.route("**/api/backups/create", r => r.fulfill({ status: 403, json: { error: "Создание запрещено" } }))
|
||||||
|
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
|
||||||
|
await expect(dialog.getByRole("button", { name: /Снять бэкап/ })).toBeEnabled()
|
||||||
|
await dialog.getByRole("button", { name: "Назад", exact: true }).click()
|
||||||
|
await expect(dialog.getByRole("textbox", { name: "Заметка резервной копии", exact: true })).toHaveValue("manual")
|
||||||
|
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
for (const kind of ["device", "S3"]) test("Бэкапы job: partial " + kind + " не называется полным успехом", async ({ page }) => {
|
||||||
|
await setupBackups(page); let id = ""
|
||||||
|
await page.route("**/api/backups/create", r => { id = r.request().postDataJSON().requestId; return r.fulfill({ status: 202, json: backupAck(id) }) })
|
||||||
|
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id, kind === "device" ? { created: [], failures: [{ serverId: "1", error: "RouterOS offline" }] } : { created: [{ ...backup, uploadError: "S3 upload denied" }] }) }))
|
||||||
|
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
|
||||||
|
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText(kind === "device" ? "RouterOS offline" : "S3 upload denied")
|
||||||
|
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0)
|
||||||
|
await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
test("Бэкапы job: отказ GET и повтор только чтения той же задачи", async ({ page }) => {
|
||||||
|
await setupBackups(page); let posts = 0, id = "", fail = true
|
||||||
|
await page.route("**/api/backups/create", r => { posts++; id = r.request().postDataJSON().requestId; return r.fulfill({ status: 202, json: backupAck(id) }) })
|
||||||
|
await page.route("**/api/backups/jobs/*", r => r.fulfill(fail ? { status: 503, json: { error: "Наблюдение отказало" } } : { json: backupJob(id) }))
|
||||||
|
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
|
||||||
|
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeEnabled()
|
||||||
|
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("Наблюдение отказало")
|
||||||
|
fail = false; await dialog.getByRole("button", { name: "Проверить задачу", exact: true }).click()
|
||||||
|
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toBeVisible(); expect(posts).toBe(1)
|
||||||
|
})
|
||||||
|
test("Бэкапы job: потерянный POST/404 после reload не запускает второй POST", async ({ page }) => {
|
||||||
|
await setupBackups(page); let posts = 0
|
||||||
|
await page.route("**/api/backups/create", r => { posts++; return r.fulfill({ status: 503, json: { error: "Неизвестный результат POST" } }) })
|
||||||
|
await page.route("**/api/backups/jobs/*", r => r.fulfill({ status: 404, json: { error: "Job lost" } }))
|
||||||
|
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
|
||||||
|
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeEnabled()
|
||||||
|
await page.reload(); await page.getByRole("button", { name: "Задача резервного копирования", exact: true }).click()
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "Проверить задачу", exact: true }).click()
|
||||||
|
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("После рестарта")
|
||||||
|
expect(posts).toBe(1); await expect(page.getByRole("dialog").getByRole("button", { name: "Новая задача", exact: true })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
test("Бэкапы job: чужая цель не подтверждает завершение", async ({ page }) => {
|
||||||
|
await setupBackups(page); let id = ""
|
||||||
|
await page.route("**/api/backups/create", r => { id = r.request().postDataJSON().requestId; return r.fulfill({ status: 202, json: backupAck(id) }) })
|
||||||
|
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id, { notes: "foreign" }) }))
|
||||||
|
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
|
||||||
|
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("не соответствует")
|
||||||
|
await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Бэкапы job: неизвестный POST восстанавливается GET без второго создания", async ({ page }) => {
|
||||||
|
await setupBackups(page); let posts = 0, id = ""
|
||||||
|
await page.route("**/api/backups/create", r => { posts++; id = r.request().postDataJSON().requestId; return r.fulfill({ status: 503, json: { error: "Ack lost" } }) })
|
||||||
|
await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id) }))
|
||||||
|
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
|
||||||
|
await expect(dialog.getByRole("button", { name: "Проверить задачу", exact: true })).toBeEnabled()
|
||||||
|
await dialog.getByRole("button", { name: "Проверить задачу", exact: true }).click()
|
||||||
|
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toBeVisible(); expect(posts).toBe(1)
|
||||||
|
})
|
||||||
|
test("Бэкапы job: невозможность checkpoint записи не отправляет POST", async ({ page }) => {
|
||||||
|
await setupBackups(page); let posts = 0
|
||||||
|
await page.addInitScript(() => { const set = Storage.prototype.setItem; Storage.prototype.setItem = function(key, value) { if (key.startsWith("mmapp:backup-job:")) throw new Error("denied"); return set.call(this, key, value) } })
|
||||||
|
await page.route("**/api/backups/create", r => { posts++; return r.fallback() })
|
||||||
|
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
|
||||||
|
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("Запрос не отправлен")
|
||||||
|
expect(posts).toBe(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test"
|
||||||
|
import { backup } from "./backups"
|
||||||
|
export async function openBackup(page: Page) {
|
||||||
|
await page.goto("/backups"); await page.getByRole("button", { name: "Новый бэкап", exact: true }).first().click()
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await dialog.getByRole("checkbox", { name: /Выбрать Роутер/ }).check()
|
||||||
|
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
|
||||||
|
await dialog.getByRole("textbox", { name: "Заметка резервной копии", exact: true }).fill("manual")
|
||||||
|
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
|
||||||
|
await expect(dialog.getByRole("button", { name: /Снять бэкап/ })).toBeEnabled()
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
export function backupJob(id: string, patch: Record<string, unknown> = {}) { return { id, status: "done", serverIds: ["1"], notes: "manual", total: 1, completed: 1, created: [backup], failures: [], requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", ...patch } }
|
||||||
|
export function backupAck(id: string) { return { jobId: id, status: "queued", serverIds: ["1"], total: 1, completed: 0 } }
|
||||||
Reference in new issue
Block a user