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 { 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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 { 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)
|
||||
})
|
||||
@@ -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