diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx
index 45ad3030..ab324af5 100644
--- a/app/(main)/backups/page.tsx
+++ b/app/(main)/backups/page.tsx
@@ -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() {
}
{opError && Операция не подтверждена{opError}}
- {(job.error || job.storageError) && {jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}{job.error || job.storageError}}
+ {(job.error || job.storageError || persistedJobError) && {jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}{job.error || job.storageError || persistedJobError}}
Резервные копии
История файлов, расписание и подтверждения операций
{ if (job.reset()) { setManualServers(new Set()); setManualNotes(""); setManualStep(1) } }}
onSubmit={handleManualBackup}
diff --git a/components/backups/backup-create-sheet.tsx b/components/backups/backup-create-sheet.tsx
index fd653dd0..e81abe85 100644
--- a/components/backups/backup-create-sheet.tsx
+++ b/components/backups/backup-create-sheet.tsx
@@ -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({
{locked ? "Задача резервного копирования" : "Новый бэкап"}
- Снять конфигурацию вручную с выбранных серверов
+ {locked ? "Подтверждённые файлы и ошибки выполнения" : "Снять конфигурацию вручную с выбранных серверов"}
- {locked && ID: {jobId}
{jobStatus}
{result &&
{result}
}
Закрытие формы не отменяет задачу. Повторная проверка не создаёт новые копии.
}
+
+ {locked &&
ID: {jobId}
{jobStatus}
{result &&
{result}
}
Закрытие формы не отменяет задачу. Повторная проверка не создаёт новые копии.
}
+ {error &&
{terminal ? "Результат с ограничениями" : "Запрос не подтверждён"}{error} }
+
}>Закрыть
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index 5c11ab44..5fcebe83 100644
--- a/docs/ux-ui-plan.md
+++ b/docs/ux-ui-plan.md
@@ -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) |
+
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index a0166eeb..7a221adf 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -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.
diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md
index 2667b289..dea3c7c0 100644
--- a/docs/ux-ui-stage-11a.md
+++ b/docs/ux-ui-stage-11a.md
@@ -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) |
+
diff --git a/docs/ux-ui/screens/backups-initial-error-desktop.png b/docs/ux-ui/screens/backups-initial-error-desktop.png
new file mode 100644
index 00000000..1f541de6
Binary files /dev/null and b/docs/ux-ui/screens/backups-initial-error-desktop.png differ
diff --git a/docs/ux-ui/screens/backups-initial-error-mobile.png b/docs/ux-ui/screens/backups-initial-error-mobile.png
new file mode 100644
index 00000000..72071c7a
Binary files /dev/null and b/docs/ux-ui/screens/backups-initial-error-mobile.png differ
diff --git a/docs/ux-ui/screens/backups-initial-error-narrow.png b/docs/ux-ui/screens/backups-initial-error-narrow.png
new file mode 100644
index 00000000..f2fe4c32
Binary files /dev/null and b/docs/ux-ui/screens/backups-initial-error-narrow.png differ
diff --git a/docs/ux-ui/screens/backups-partial-job-desktop.png b/docs/ux-ui/screens/backups-partial-job-desktop.png
new file mode 100644
index 00000000..91540a08
Binary files /dev/null and b/docs/ux-ui/screens/backups-partial-job-desktop.png differ
diff --git a/docs/ux-ui/screens/backups-partial-job-mobile.png b/docs/ux-ui/screens/backups-partial-job-mobile.png
new file mode 100644
index 00000000..d9282b91
Binary files /dev/null and b/docs/ux-ui/screens/backups-partial-job-mobile.png differ
diff --git a/docs/ux-ui/screens/backups-partial-job-narrow.png b/docs/ux-ui/screens/backups-partial-job-narrow.png
new file mode 100644
index 00000000..d898810b
Binary files /dev/null and b/docs/ux-ui/screens/backups-partial-job-narrow.png differ
diff --git a/docs/ux-ui/screens/backups-s3-error-desktop.png b/docs/ux-ui/screens/backups-s3-error-desktop.png
new file mode 100644
index 00000000..b671c54b
Binary files /dev/null and b/docs/ux-ui/screens/backups-s3-error-desktop.png differ
diff --git a/docs/ux-ui/screens/backups-s3-error-mobile.png b/docs/ux-ui/screens/backups-s3-error-mobile.png
new file mode 100644
index 00000000..ebb8acce
Binary files /dev/null and b/docs/ux-ui/screens/backups-s3-error-mobile.png differ
diff --git a/docs/ux-ui/screens/backups-s3-error-narrow.png b/docs/ux-ui/screens/backups-s3-error-narrow.png
new file mode 100644
index 00000000..40cfeb0c
Binary files /dev/null and b/docs/ux-ui/screens/backups-s3-error-narrow.png differ
diff --git a/lib/backup-job-poll.test.ts b/lib/backup-job-poll.test.ts
index b4cb3f7d..d5a309f9 100644
--- a/lib/backup-job-poll.test.ts
+++ b/lib/backup-job-poll.test.ts
@@ -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" }) ?? "", /Задача завершилась с ошибкой/)
+})
diff --git a/lib/backup-job-poll.ts b/lib/backup-job-poll.ts
index 8a247497..a0ab2ae5 100644
--- a/lib/backup-job-poll.ts
+++ b/lib/backup-job-poll.ts
@@ -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("; ") || "Задача завершилась с ошибкой")
+}
diff --git a/lib/use-backup-job.ts b/lib/use-backup-job.ts
index 18814302..9f35ce57 100644
--- a/lib/use-backup-job.ts
+++ b/lib/use-backup-job.ts
@@ -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
diff --git a/tests/e2e/backups-states-layout.spec.ts b/tests/e2e/backups-states-layout.spec.ts
new file mode 100644
index 00000000..bc122962
--- /dev/null
+++ b/tests/e2e/backups-states-layout.spec.ts
@@ -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 })
+ })
+}