fix(backups): продолжать наблюдение без повторного создания

This commit is contained in:
Denozordec committed 2026-10-01 15:58:47 +07:00
1 parent 4aee2a34f1
commit 714af7e872
9 files changed
+265 -74

No files matched your search

+32 -66
View File
@@ -23,9 +23,7 @@ import { can } from "@/lib/auth"
import { userApiError } from "@/shared/api/users"
import { toFrontendServer } from "@/entities/server/model/mappers"
import {
createBackupsAsync,
deleteBackup,
getBackupJob,
getBackupScheduleSettings,
getBackupStorageSettings,
listBackups,
@@ -38,6 +36,8 @@ import {
} from "@/shared/api/backups"
import { ApiClientError, requestJson, requestBlob } from "@/shared/api/http-client"
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 { BackupsHistory } from "@/components/backups/backups-history"
import {
@@ -85,11 +85,10 @@ function BackupsContent() {
const [search, setSearch] = useState("")
const [advancedFilters, setAdvancedFilters] = useState<FilterQuery>(() => createFilterQuery())
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 [opBusy, setOpBusy] = useState(false)
const [opError, setOpError] = useState<string | null>(null)
const [backupJobId, setBackupJobId] = useState<string | null>(null)
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
const [testBusy, setTestBusy] = useState(false)
const [syncBusy, setSyncBusy] = useState(false)
@@ -165,6 +164,12 @@ function BackupsContent() {
return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort() }
}, [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() {
if (!alive.current || settingsLock.current || opBusy || backupJobId) return null
settingsLock.current = true
@@ -246,60 +251,12 @@ function BackupsContent() {
}
async function handleManualBackup() {
if (!canCreate || manualServers.size === 0 || opBusy) return
setOpError(null)
try {
const res = await createBackupsAsync(backendUrl, {
serverIds: [...manualServers],
notes: manualNotes || undefined,
})
setBackupJobId(res.jobId)
setManualOpen(false)
setManualServers(new Set())
setManualNotes("")
setTab("history")
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка создания бэкапа")
}
if (checkpoint) { await job.run(); return }
if (!canCreate || !manualServers.size || settingsLock.current || opBusy) return
if ([...manualServers].some(id => !liveServers.some(server => server.id === id))) { setOpError("Выбранный сервер отсутствует; обновите выбор"); return }
await job.run({ serverIds: [...manualServers], notes: manualNotes || undefined })
}
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(() => {
if (opError) toast.error(opError)
}, [opError])
@@ -377,6 +334,7 @@ function BackupsContent() {
: "default"
function openCreate(all: boolean) {
if (checkpoint) { setManualStep(3); setManualOpen(true); return }
if (!canCreate) return
setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
setManualNotes("")
@@ -395,7 +353,7 @@ function BackupsContent() {
variant="outline"
size="sm"
onClick={() => openCreate(true)}
disabled={!canCreate || loading}
disabled={!canCreate || loading || job.busy}
>
<RefreshCwIcon className="size-4" />
Снять со всех
@@ -404,10 +362,10 @@ function BackupsContent() {
type="button"
size="sm"
onClick={() => openCreate(false)}
disabled={!canCreate || loading}
disabled={checkpoint ? !canRead : !canCreate || loading || job.busy}
>
<PlusIcon className="size-4" />
Новый бэкап
{checkpoint ? "Задача резервного копирования" : "Новый бэкап"}
</Button>
</>
}
@@ -428,6 +386,7 @@ function BackupsContent() {
</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
aria-label="Сводка бэкапов"
items={[
@@ -465,11 +424,11 @@ function BackupsContent() {
<Alert variant={alertVariant}>
<ClockIcon />
<AlertTitle>
{backupJobId ? "Задача выполняется" : schedSummary}
{backupJobId ? (checkpoint?.accepted ? "Задача наблюдается" : "Результат запроса пока неизвестен") : schedSummary}
</AlertTitle>
<AlertDescription>
{backupJobId
? "Снимок конфигурации снимается в фоне. Список обновится после завершения."
? "Закрытие формы не отменяет задачу. Проверка продолжает чтение того же ID."
: statusDetail}
</AlertDescription>
<AlertAction>
@@ -532,8 +491,8 @@ function BackupsContent() {
scheduleKnown={known("schedule")}
storageKnown={known("storage")}
storageDirty={storageIsDirty}
scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers")}
storageWritable={canWrite && current("storage")}
scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers") && !backupJobId}
storageWritable={canWrite && current("storage") && !backupJobId}
allEnabled={allEnabled}
onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }}
onTest={handleTestStorage}
@@ -554,7 +513,7 @@ function BackupsContent() {
step={manualStep}
onStepChange={setManualStep}
servers={liveServers}
selected={manualServers}
selected={checkpoint ? new Set(checkpoint.request.serverIds.map(String)) : manualServers}
onToggle={(id) => {
setManualServers((prev) => {
const next = new Set(prev)
@@ -565,10 +524,17 @@ function BackupsContent() {
}}
onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
onClear={() => setManualServers(new Set())}
notes={manualNotes}
notes={checkpoint?.request.notes ?? manualNotes}
onNotesChange={setManualNotes}
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}
/>
+25 -6
View File
@@ -43,6 +43,13 @@ export function BackupCreateSheet({
destinationLabel,
busy,
onSubmit,
locked = false,
terminal = false,
canObserve = true,
jobId,
jobStatus,
result,
onReset,
}: {
open: boolean
onOpenChange: (open: boolean) => void
@@ -57,16 +64,25 @@ export function BackupCreateSheet({
onNotesChange: (value: string) => void
destinationLabel: string
busy: boolean
locked?: boolean
terminal?: boolean
canObserve?: boolean
jobId?: string
jobStatus?: string
result?: string
onReset: () => void
onSubmit: () => void
}) {
return (
<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">
<SheetTitle>Новый бэкап</SheetTitle>
<SheetTitle>{locked ? "Задача резервного копирования" : "Новый бэкап"}</SheetTitle>
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
</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">
<StepperNav className="mb-5">
<StepperItem step={1}>
@@ -141,6 +157,8 @@ export function BackupCreateSheet({
<FormField label="Заметка">
<Input
placeholder="Например: перед обновлением BGP"
aria-label="Заметка резервной копии"
maxLength={500}
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
/>
@@ -157,15 +175,16 @@ export function BackupCreateSheet({
</StepperContent>
</StepperPanel>
</Stepper>
</fieldset>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
{step > 1 && (
<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>
{!locked && step > 1 && (
<Button type="button" variant="outline" className="flex-1" onClick={() => onStepChange(step - 1)}>
Назад
</Button>
)}
{step < 3 ? (
{locked ? <><Button type="button" disabled={!canObserve || busy || terminal} onClick={onSubmit}>Проверить задачу</Button>{terminal && <Button type="button" onClick={onReset}>Новая задача</Button>}</> : step < 3 ? (
<Button
type="button"
className="flex-1"
+1 -1
View File
@@ -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/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
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 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Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 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)
- Create request принимает необязательный UUID requestId; job/ack содержат canonical serverIds, job содержит заметку. Повтор той же цели/заметки в том же backend процессе возвращает прежний job; конфликт ID возвращает 409. Конкурентный повтор после async catalog lookup резервируется одним Map insert, без второй очереди. Старые клиенты без ID получают отдельные UUID.
+9
View File
@@ -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; подключение в следующем блоке.
- 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 — сохраняемая задача создания резервных копий (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.
+70
View File
@@ -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 }
}
+27
View File
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test"
import { openBackup, backupJob, backupAck } from "./fixtures/backup-job"
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" }
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(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)
})
+77
View File
@@ -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)
})
+14
View File
@@ -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 } }