fix(backups): показывать частичный результат внутри формы

This commit is contained in:
Denozordec committed 2026-10-01 20:39:35 +07:00
1 parent ad457b90a9
commit 98b4c39a00
18 files changed
+112 -16

No files matched your search

+5 -3
View File
@@ -41,7 +41,7 @@ import { useBackupRestoreGuard } from "@/lib/use-backup-restore-guard"
import { restoreWasNotApplied } from "@/lib/backup-restore-checkpoint"
import { Checkbox } from "@/components/ui/checkbox"
import { useBackupJob } from "@/lib/use-backup-job"
import { backupJobOutcome } from "@/lib/backup-job-poll"
import { backupJobOutcome, backupJobResultError } from "@/lib/backup-job-poll"
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
import { BackupsHistory } from "@/components/backups/backups-history"
import {
@@ -175,6 +175,7 @@ function BackupsContent() {
const checkpoint = job.checkpoint
const jobTerminal = !!checkpoint?.job && ["done", "failed"].includes(checkpoint.job.status)
const backupJobId = checkpoint && !jobTerminal ? checkpoint.request.requestId : null
const persistedJobError = checkpoint?.job && jobTerminal ? backupJobResultError(checkpoint.job) : null
const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError
function startSettingsMutation() {
@@ -419,7 +420,7 @@ function BackupsContent() {
</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>}
{(job.error || job.storageError || persistedJobError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError || persistedJobError}</AlertDescription></Alert>}
<div><h1 className="text-xl font-semibold">Резервные копии</h1><p className="text-sm text-muted-foreground">История файлов, расписание и подтверждения операций</p></div>
<KpiStatGrid
aria-label="Сводка бэкапов"
@@ -569,7 +570,8 @@ function BackupsContent() {
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 ? "Запрос принят, результат ещё неизвестен" : "Результат запроса ещё неизвестен"}
jobStatus={checkpoint?.job ? ({ queued: "В очереди", running: "Выполняется", done: checkpoint?.job && backupJobOutcome(checkpoint.job).fullSuccess ? "Завершена" : "Завершена с ограничениями", failed: "Прервана" })[checkpoint.job.status] + ": " + checkpoint.job.completed + "/" + checkpoint.job.total : checkpoint?.accepted ? "Запрос принят, результат ещё неизвестен" : "Результат запроса ещё неизвестен"}
error={job.error || job.storageError || persistedJobError}
result={checkpoint?.job && jobTerminal ? "Создано " + backupJobOutcome(checkpoint.job).created + " из " + checkpoint.job.total : undefined}
onReset={() => { if (job.reset()) { setManualServers(new Set()); setManualNotes(""); setManualStep(1) } }}
onSubmit={handleManualBackup}
+11 -5
View File
@@ -26,6 +26,7 @@ import {
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { cn } from "@/lib/utils"
export function BackupCreateSheet({
@@ -49,6 +50,7 @@ export function BackupCreateSheet({
jobId,
jobStatus,
result,
error,
onReset,
}: {
open: boolean
@@ -70,6 +72,7 @@ export function BackupCreateSheet({
jobId?: string
jobStatus?: string
result?: string
error?: string | null
onReset: () => void
onSubmit: () => void
}) {
@@ -78,10 +81,12 @@ export function BackupCreateSheet({
<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>{locked ? "Задача резервного копирования" : "Новый бэкап"}</SheetTitle>
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
<SheetDescription>{locked ? "Подтверждённые файлы и ошибки выполнения" : "Снять конфигурацию вручную с выбранных серверов"}</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>}
<div className="min-h-0 flex-1 overflow-y-auto">
{locked && <div role="status" className="px-6 py-3 text-sm [overflow-wrap:anywhere]"><p>ID: {jobId}</p><p>{jobStatus}</p>{result && <p>{result}</p>}<p className="text-xs text-muted-foreground">Закрытие формы не отменяет задачу. Повторная проверка не создаёт новые копии.</p></div>}
{error && <div className="px-6 pb-3"><Alert variant="warning" aria-label="Результат задачи"><AlertTitle>{terminal ? "Результат с ограничениями" : "Запрос не подтверждён"}</AlertTitle><AlertDescription className="[overflow-wrap:anywhere]">{error}</AlertDescription></Alert></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">
@@ -166,16 +171,17 @@ export function BackupCreateSheet({
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-3 text-sm">
<p className="text-muted-foreground">
Будет создан бэкап для <strong className="text-foreground">{selected.size}</strong> серверов.
{locked ? "Запрошены резервные копии для" : "Будет создан бэкап для"} <strong className="text-foreground">{selected.size}</strong> серверов.
</p>
<p className="text-muted-foreground">
{!locked && <p className="text-muted-foreground">
Куда сохранится: <strong className="text-foreground">{destinationLabel}</strong>
</p>
</p>}
{notes ? <p className="text-muted-foreground">Заметка: {notes}</p> : null}
</StepperContent>
</StepperPanel>
</Stepper>
</fieldset>
</div>
<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>
+8 -1
View File
@@ -226,7 +226,7 @@ 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А.31.2.2 — S3/partial job/initial errors и визуальная приёмка резервных копий. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем 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А ещё не приняты.
Актуальная точка продолжения: 11А.32 — backup scheduler cleanup, затем профильная приёмка backups. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем 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А ещё не приняты.
| Экран | ПК | Телефон |
| --- | --- | --- |
@@ -242,3 +242,10 @@ REST запрос → ожидание → подтверждённый output /
| Ошибка удаления | [PNG](ux-ui/screens/backups-delete-error-desktop.png) | [390](ux-ui/screens/backups-delete-error-mobile.png), [320](ux-ui/screens/backups-delete-error-narrow.png) |
| Неизвестный импорт | [PNG](ux-ui/screens/backups-restore-unknown-desktop.png) | [390](ux-ui/screens/backups-restore-unknown-mobile.png), [320](ux-ui/screens/backups-restore-unknown-narrow.png) |
| Экран | ПК | Телефон |
| --- | --- | --- |
| S3 ошибка | [PNG](ux-ui/screens/backups-s3-error-desktop.png) | [390](ux-ui/screens/backups-s3-error-mobile.png), [320](ux-ui/screens/backups-s3-error-narrow.png) |
| Partial job | [PNG](ux-ui/screens/backups-partial-job-desktop.png) | [390](ux-ui/screens/backups-partial-job-mobile.png), [320](ux-ui/screens/backups-partial-job-narrow.png) |
| Initial error | [PNG](ux-ui/screens/backups-initial-error-desktop.png) | [390](ux-ui/screens/backups-initial-error-mobile.png), [320](ux-ui/screens/backups-initial-error-narrow.png) |
+14 -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А.31.2.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: Frame/диалоги, далее 11А.31.2.2 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.31.2.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: визуальные состояния, далее 11А.32 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,19 @@
## Блок 11А.31.2.2 — визуальные состояния S3, partial job и initial error (2026-10-01)
- Проверены ПК/390/320: S3 отказ доступа с актуальным backend receipt, partial upload при созданной локальной копии, первоначальный отказ истории. Unknown не превращается в empty/нулевые KPI, старая проверка S3 не остаётся успешной после подтверждённой ошибки. Добавлены 9 PNG.
- Просмотр partial job выявил скрытую под Sheet ошибку. Ошибка и «Завершена с ограничениями» теперь показаны внутри формы; общий parent-owned scroll оставляет footer доступным. Результат после reload восстанавливается из terminal checkpoint, не зависит от прежнего состояния hook error. Locked запрос говорит о выбранной цели, не обещает будущее сохранение в текущую конфигурацию хранилища.
- 9 states + 9 job E2E (повтор с reload partial), 3 layout регрессии предыдущим прогоном, 6 polling/result unit, TypeScript/профильный ESLint и production frontend прошли. ReUI audit checklist получен; установленная композиция/semantic tokens/typed API сохранены, клавиатура/scroll/ошибки проверены в профиле. Реальная DB/RouterOS/S3 остаётся внешней приёмкой.
- **Следующий блок 11А.32:** backup scheduler cleanup — lock на всех выходах/setup failure, независимость журнала от подтверждённых файлов, отдельные prune/metadata сбои. Затем 11А.33 профильная локальная приёмка backups; весь 11А ещё не принят.
| Экран | ПК | Телефон |
| --- | --- | --- |
| S3 ошибка | [PNG](ux-ui/screens/backups-s3-error-desktop.png) | [390](ux-ui/screens/backups-s3-error-mobile.png), [320](ux-ui/screens/backups-s3-error-narrow.png) |
| Partial job | [PNG](ux-ui/screens/backups-partial-job-desktop.png) | [390](ux-ui/screens/backups-partial-job-mobile.png), [320](ux-ui/screens/backups-partial-job-narrow.png) |
| Initial error | [PNG](ux-ui/screens/backups-initial-error-desktop.png) | [390](ux-ui/screens/backups-initial-error-mobile.png), [320](ux-ui/screens/backups-initial-error-narrow.png) |
## Блок 11А.31.2.1 — ошибки и подтверждения в узких диалогах (2026-10-01)
- Delete/restore описания переносят непрерывное имя файла. Предупреждение изменения рабочей конфигурации не обрезается line-clamp на 320 px. Ошибка удаления сохраняет цель; unknown restore оставляет disabled import/«Закрыть», Escape не объявляет отмену RouterOS.
+14
View File
@@ -447,3 +447,17 @@
| Ошибка удаления | [PNG](ux-ui/screens/backups-delete-error-desktop.png) | [390](ux-ui/screens/backups-delete-error-mobile.png), [320](ux-ui/screens/backups-delete-error-narrow.png) |
| Неизвестный импорт | [PNG](ux-ui/screens/backups-restore-unknown-desktop.png) | [390](ux-ui/screens/backups-restore-unknown-mobile.png), [320](ux-ui/screens/backups-restore-unknown-narrow.png) |
## Блок 11А.31.2.2 — визуальные состояния S3, partial job и initial error (2026-10-01)
- Проверены ПК/390/320: S3 отказ доступа с актуальным backend receipt, partial upload при созданной локальной копии, первоначальный отказ истории. Unknown не превращается в empty/нулевые KPI, старая проверка S3 не остаётся успешной после подтверждённой ошибки. Добавлены 9 PNG.
- Просмотр partial job выявил скрытую под Sheet ошибку. Ошибка и «Завершена с ограничениями» теперь показаны внутри формы; общий parent-owned scroll оставляет footer доступным. Результат после reload восстанавливается из terminal checkpoint, не зависит от прежнего состояния hook error. Locked запрос говорит о выбранной цели, не обещает будущее сохранение в текущую конфигурацию хранилища.
- 9 states + 9 job E2E (повтор с reload partial), 3 layout регрессии предыдущим прогоном, 6 polling/result unit, TypeScript/профильный ESLint и production frontend прошли. ReUI audit checklist получен; установленная композиция/semantic tokens/typed API сохранены, клавиатура/scroll/ошибки проверены в профиле. Реальная DB/RouterOS/S3 остаётся внешней приёмкой.
- **Следующий блок 11А.32:** backup scheduler cleanup — lock на всех выходах/setup failure, независимость журнала от подтверждённых файлов, отдельные prune/metadata сбои. Затем 11А.33 профильная локальная приёмка backups; весь 11А ещё не принят.
| Экран | ПК | Телефон |
| --- | --- | --- |
| S3 ошибка | [PNG](ux-ui/screens/backups-s3-error-desktop.png) | [390](ux-ui/screens/backups-s3-error-mobile.png), [320](ux-ui/screens/backups-s3-error-narrow.png) |
| Partial job | [PNG](ux-ui/screens/backups-partial-job-desktop.png) | [390](ux-ui/screens/backups-partial-job-mobile.png), [320](ux-ui/screens/backups-partial-job-narrow.png) |
| Initial error | [PNG](ux-ui/screens/backups-initial-error-desktop.png) | [390](ux-ui/screens/backups-initial-error-mobile.png), [320](ux-ui/screens/backups-initial-error-narrow.png) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+10 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { pollBackupJob, backupJobOutcome, waitForBackupPoll } from "./backup-job-poll"
import { pollBackupJob, backupJobOutcome, backupJobResultError, waitForBackupPoll } from "./backup-job-poll"
import type { BackupJobStatusResponse } from "@mmapp/contracts/backups"
const target = { serverIds: ["1"], notes: "manual" }
const baseJob: BackupJobStatusResponse = { id: "job", status: "queued", serverIds: ["1"], notes: "manual", total: 1, completed: 0, requestedAt: "2026-10-01T00:00:00Z", created: [], failures: [] }
@@ -30,3 +30,12 @@ test("cancellation interrupts delay and suppresses late observation", async () =
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: late.signal, onJob: () => { calls++ }, readJob: async () => { late.abort(); return baseJob } }))
assert.equal(calls, 0)
})
test("saved terminal job reconstructs partial errors without a previous hook error", () => {
assert.equal(backupJobResultError(baseJob), null)
const success: BackupJobStatusResponse = { ...baseJob, status: "done", completed: 1, created: [file] }
assert.equal(backupJobResultError(success), null)
assert.match(backupJobResultError({ ...success, created: [{ ...file, uploadError: "S3 unavailable" }] }) ?? "", /config.rsc: S3 unavailable/)
assert.match(backupJobResultError({ ...success, created: [], failures: [{ serverId: "1", error: "offline" }] }) ?? "", /Создано 0 из 1.*1: offline/)
assert.match(backupJobResultError({ ...baseJob, status: "failed" }) ?? "", /Задача завершилась с ошибкой/)
})
+7
View File
@@ -27,3 +27,10 @@ export function backupJobOutcome(job: BackupJobStatusResponse) {
const uploadFailures = job.created.filter(item => item.uploadError)
return { fullSuccess: job.status === "done" && job.created.length === job.total && !job.failures.length && !uploadFailures.length, created: job.created.length, deviceFailures: job.failures, uploadFailures }
}
export function backupJobResultError(job: BackupJobStatusResponse): string | null {
const outcome = backupJobOutcome(job)
if (!["done", "failed"].includes(job.status) || outcome.fullSuccess) return null
const failures = [...outcome.deviceFailures.map(failure => failure.serverId + ": " + failure.error), ...outcome.uploadFailures.map(file => file.filename + ": " + file.uploadError)]
return "Создано " + outcome.created + " из " + job.total + ". " + (failures.join("; ") || "Задача завершилась с ошибкой")
}
+2 -5
View File
@@ -3,7 +3,7 @@ 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 { pollBackupJob, backupJobOutcome, backupJobResultError } 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"
@@ -50,10 +50,7 @@ export function useBackupJob(backendUrl: string, live: boolean, canRead: boolean
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("; ") || "Задача завершилась с ошибкой"))
}
else setError(backupJobResultError(result))
await onDone()
} catch (reason) {
if (!active()) return
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test"
import { backup, storage, setupBackups } from "./fixtures/backups"
import { openBackup, backupAck, backupJob } from "./fixtures/backup-job"
for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) {
test("Бэкапы: S3 ошибка и адаптивность/" + name, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await setupBackups(page)
const s3 = { ...storage, provider: "s3", s3Endpoint: "https://very-long-endpoint-of-backup-storage.example.test", s3Bucket: "long-backup-bucket-name", s3AccessKeyId: "test-access", secretConfigured: true, lastTestAt: "2026-10-01T00:00:00Z" }
await page.route("**/api/backups/storage", r => r.fulfill({ json: s3 }))
await page.route("**/api/backups/storage/test", r => r.fulfill({ status: 400, json: { error: "Доступ к S3 отклонён. Проверьте сохранённые ключи и права на bucket.", settings: { ...s3, lastTestAt: "2026-10-01T01:00:00Z", lastTestError: "Доступ к S3 отклонён. Проверьте сохранённые ключи и права на bucket." } } }))
await page.goto("/backups?tab=settings"); await expect(page.getByText("Проверено ранее", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Проверить", exact: true }).click(); await expect(page.getByRole("alert", { name: "Операция резервных копий", exact: true })).toContainText("Доступ к S3 отклонён")
await expect(page.getByText("Проверено ранее", { exact: true })).toHaveCount(0); await expect(page.getByText("Доступ к S3 подтверждён", { exact: true })).toHaveCount(0)
await page.getByLabel("Bucket", { exact: true }).scrollIntoViewIfNeeded()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true)
expect(await page.locator("fieldset").evaluateAll(elements => elements.every(element => element.scrollWidth <= element.clientWidth + 1))).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/backups-s3-error-" + name + ".png", fullPage: true })
})
test("Бэкапы: partial job и адаптивность/" + name, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); 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, { created: [{ ...backup, uploadError: "Загрузка в S3 не подтверждена; локальная копия создана и сохранена." }] }) }))
const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click()
await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("Создано 1 из 1")
await expect(dialog.getByRole("alert", { name: "Результат задачи", exact: true })).toContainText("Загрузка в S3 не подтверждена")
await expect(dialog).toContainText("Завершена с ограничениями")
await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0); await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toBeVisible()
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/backups-partial-job-" + name + ".png", fullPage: true })
await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0)
await page.reload(); await page.getByRole("button", { name: "Задача резервного копирования", exact: true }).first().click()
await expect(page.getByRole("dialog").getByRole("alert", { name: "Результат задачи", exact: true })).toContainText("Загрузка в S3 не подтверждена")
})
test("Бэкапы: initial error не превращается в пустую историю/" + name, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await setupBackups(page)
await page.route("**/api/backups", r => r.fulfill({ status: 503, json: { error: "История недоступна: повторите чтение данных." } }))
await page.goto("/backups"); await expect(page.getByRole("alert", { name: "Данные резервных копий: backups", exact: true })).toContainText("Данные пока неизвестны")
await expect(page.getByText("Нет бэкапов", { exact: true })).toHaveCount(0); await expect(page.getByLabel("Сводка бэкапов", { exact: true })).toContainText("—")
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/backups-initial-error-" + name + ".png", fullPage: true })
})
}