fix(backups): сохранять неизвестный результат восстановления
This commit is contained in:
1 parent
39570656c9
commit
6f152c8dfe
11 files changed
+159
-11
No files matched your search
@@ -37,6 +37,9 @@ import {
|
|||||||
} from "@/shared/api/backups"
|
} from "@/shared/api/backups"
|
||||||
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
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 { useBackupJob } from "@/lib/use-backup-job"
|
||||||
import { backupJobOutcome } from "@/lib/backup-job-poll"
|
import { backupJobOutcome } from "@/lib/backup-job-poll"
|
||||||
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
|
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
|
||||||
@@ -91,6 +94,8 @@ function BackupsContent() {
|
|||||||
const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers)
|
const [liveServers, setLiveServers] = useState<Server[]>(isLive ? [] : mockServers)
|
||||||
const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
|
const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const restoreGuard = useBackupRestoreGuard(backendUrl, isLive)
|
||||||
|
const [restoreReviewed, setRestoreReviewed] = useState(false)
|
||||||
const [opBusy, setOpBusy] = useState(false)
|
const [opBusy, setOpBusy] = useState(false)
|
||||||
const [opError, setOpError] = useState<string | null>(null)
|
const [opError, setOpError] = useState<string | null>(null)
|
||||||
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
||||||
@@ -292,15 +297,23 @@ function BackupsContent() {
|
|||||||
finally { finishOperation(abort) }
|
finally { finishOperation(abort) }
|
||||||
}
|
}
|
||||||
async function handleRestore() {
|
async function handleRestore() {
|
||||||
if (!restoreTarget || !restoreTarget.serverId) return
|
if (!restoreTarget || !restoreTarget.serverId || !restoreGuard.canStart) return
|
||||||
const target = restoreTarget, abort = startOperation(); if (!abort) return
|
const target = restoreTarget, abort = startOperation(); if (!abort) return
|
||||||
|
let sent = false
|
||||||
try {
|
try {
|
||||||
|
if (!restoreGuard.begin({ backupId: target.id, serverId: Number(target.serverId), filename: target.filename, serverName: target.server })) return
|
||||||
|
sent = true
|
||||||
const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }, Number(target.serverId))
|
const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }, Number(target.serverId))
|
||||||
if (!alive.current || abort.signal.aborted) return
|
if (!alive.current || abort.signal.aborted) return
|
||||||
if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу")
|
if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу")
|
||||||
toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName)
|
toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName)
|
||||||
setRestoreTarget(null)
|
restoreGuard.clear(); setRestoreTarget(null)
|
||||||
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
|
} catch (error) {
|
||||||
|
if (alive.current && !abort.signal.aborted) {
|
||||||
|
if (sent && restoreWasNotApplied(error)) restoreGuard.clear()
|
||||||
|
setOpError(userApiError(error) + (sent && !restoreWasNotApplied(error) ? ". Результат импорта неизвестен; проверьте роутер перед повтором" : ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
finally { finishOperation(abort) }
|
finally { finishOperation(abort) }
|
||||||
}
|
}
|
||||||
async function handleDownload(id: string, filename: string) {
|
async function handleDownload(id: string, filename: string) {
|
||||||
@@ -399,6 +412,14 @@ function BackupsContent() {
|
|||||||
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
|
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
|
||||||
</Alert>)}
|
</Alert>)}
|
||||||
|
|
||||||
|
{isLive && (restoreGuard.checkpoint || restoreGuard.error) && <Alert variant="warning" aria-label="Подтверждение восстановления">
|
||||||
|
<AlertTitle>{opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"}</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-col gap-3">
|
||||||
|
<span>{restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")}</span>
|
||||||
|
<label className="flex items-start gap-2"><Checkbox checked={restoreReviewed} disabled={opBusy || !canWrite} onCheckedChange={value => setRestoreReviewed(value === true)} />Я проверил состояние роутера и понимаю последствия повторного импорта</label>
|
||||||
|
<Button type="button" variant="outline" disabled={!restoreReviewed || opBusy || !canWrite} onClick={() => { if (restoreGuard.clear()) { setRestoreReviewed(false); setOpError(null) } }}>Разрешить новое восстановление</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>}
|
||||||
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
|
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
|
||||||
{(job.error || job.storageError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError}</AlertDescription></Alert>}
|
{(job.error || job.storageError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError}</AlertDescription></Alert>}
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
@@ -476,6 +497,7 @@ function BackupsContent() {
|
|||||||
onCreate={() => openCreate(false)}
|
onCreate={() => openCreate(false)}
|
||||||
canCreate={canCreate}
|
canCreate={canCreate}
|
||||||
canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId}
|
canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId}
|
||||||
|
canRestore={restoreGuard.canStart}
|
||||||
canDownload={isLive && canRead}
|
canDownload={isLive && canRead}
|
||||||
/>}
|
/>}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -562,6 +584,7 @@ function BackupsContent() {
|
|||||||
<BackupRestoreDialog
|
<BackupRestoreDialog
|
||||||
backup={restoreTarget}
|
backup={restoreTarget}
|
||||||
busy={opBusy}
|
busy={opBusy}
|
||||||
|
blocked={!restoreGuard.canStart}
|
||||||
error={opError}
|
error={opError}
|
||||||
onConfirm={() => void handleRestore()}
|
onConfirm={() => void handleRestore()}
|
||||||
onCancel={() => setRestoreTarget(null)}
|
onCancel={() => setRestoreTarget(null)}
|
||||||
|
|||||||
@@ -57,12 +57,14 @@ export function BackupDeleteDialog({
|
|||||||
export function BackupRestoreDialog({
|
export function BackupRestoreDialog({
|
||||||
backup,
|
backup,
|
||||||
busy,
|
busy,
|
||||||
|
blocked = false,
|
||||||
error,
|
error,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
backup: Backup | null
|
backup: Backup | null
|
||||||
busy: boolean
|
busy: boolean
|
||||||
|
blocked?: boolean
|
||||||
error?: string | null
|
error?: string | null
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
@@ -91,8 +93,8 @@ export function BackupRestoreDialog({
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
{error && <p role="alert" className="text-sm text-destructive break-words">{error}</p>}
|
{error && <p role="alert" className="text-sm text-destructive break-words">{error}</p>}
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
<AlertDialogCancel disabled={busy} onClick={onCancel}>{blocked && !busy ? "Закрыть" : "Отмена"}</AlertDialogCancel>
|
||||||
<AlertDialogAction disabled={busy} onClick={onConfirm}>
|
<AlertDialogAction disabled={busy || blocked} onClick={onConfirm}>
|
||||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||||
Восстановить
|
Восстановить
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function BackupsHistory({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
canCreate = true,
|
canCreate = true,
|
||||||
|
canRestore = true,
|
||||||
canWrite = true,
|
canWrite = true,
|
||||||
canDownload = true,
|
canDownload = true,
|
||||||
}: {
|
}: {
|
||||||
@@ -43,6 +44,7 @@ export function BackupsHistory({
|
|||||||
onRestore: (backup: Backup) => void
|
onRestore: (backup: Backup) => void
|
||||||
onDelete: (backup: Backup) => void
|
onDelete: (backup: Backup) => void
|
||||||
canCreate?: boolean
|
canCreate?: boolean
|
||||||
|
canRestore?: boolean
|
||||||
canWrite?: boolean
|
canWrite?: boolean
|
||||||
canDownload?: boolean
|
canDownload?: boolean
|
||||||
onCreate: () => void
|
onCreate: () => void
|
||||||
@@ -82,6 +84,7 @@ export function BackupsHistory({
|
|||||||
onDownload={onDownload}
|
onDownload={onDownload}
|
||||||
onRestore={onRestore}
|
onRestore={onRestore}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
canRestore={canRestore}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
canDownload={canDownload}
|
canDownload={canDownload}
|
||||||
emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"}
|
emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface BackupsDataGridProps {
|
|||||||
onDownload: (id: string, filename: string) => void
|
onDownload: (id: string, filename: string) => void
|
||||||
onRestore: (backup: Backup) => void
|
onRestore: (backup: Backup) => void
|
||||||
onDelete: (backup: Backup) => void
|
onDelete: (backup: Backup) => void
|
||||||
|
canRestore?: boolean
|
||||||
canWrite?: boolean
|
canWrite?: boolean
|
||||||
canDownload?: boolean
|
canDownload?: boolean
|
||||||
emptyAction?: ReactNode
|
emptyAction?: ReactNode
|
||||||
@@ -50,6 +51,7 @@ function BackupsDataGrid({
|
|||||||
onDownload,
|
onDownload,
|
||||||
onRestore,
|
onRestore,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
canRestore = true,
|
||||||
canWrite = true,
|
canWrite = true,
|
||||||
canDownload = true,
|
canDownload = true,
|
||||||
emptyAction,
|
emptyAction,
|
||||||
@@ -207,7 +209,7 @@ function BackupsDataGrid({
|
|||||||
className="size-7"
|
className="size-7"
|
||||||
title="Восстановить"
|
title="Восстановить"
|
||||||
aria-label={`Восстановить ${b.filename}`}
|
aria-label={`Восстановить ${b.filename}`}
|
||||||
disabled={!canWrite || !b.serverId}
|
disabled={!canWrite || !canRestore || !b.serverId}
|
||||||
onClick={() => onRestore(b)}
|
onClick={() => onRestore(b)}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-3.5" />
|
<RefreshCwIcon className="size-3.5" />
|
||||||
@@ -235,7 +237,7 @@ function BackupsDataGrid({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[onDelete, onDownload, onRestore, canWrite, canDownload],
|
[onDelete, onDownload, onRestore, canWrite, canRestore, canDownload],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useTable({
|
const table = useTable({
|
||||||
|
|||||||
+1
-1
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
|
|||||||
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
|
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
|
||||||
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
|
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
|
||||||
|
|
||||||
Актуальная точка продолжения: 11А.29.5 — клиентский checkpoint restore и повтор после проверки роутера. 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А.30 — URL/Filters контекст резервных копий. Клиентский 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А ещё не приняты.
|
||||||
+10
-1
@@ -27,7 +27,7 @@
|
|||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.4; пользователи/контейнеры/сертификаты приняты локально; резервные копии: неизвестный outcome backend, далее 11А.29.5 |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.29.5; пользователи/контейнеры/сертификаты приняты локально; резервные копии: checkpoint restore, далее 11А.30 |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11А.29.5 — отметка неизвестного restore до POST (2026-10-01)
|
||||||
|
|
||||||
|
- До POST записывается immutable backupId/serverId/filename/имя/время в sessionStorage по backend URL. Отказ записи не отправляет импорт. Синхронный guard и общий operation lock защищают повтор; hydration/corrupt checkpoint не допускают начало.
|
||||||
|
- Обрыв, deadline, чужой receipt, backend unknown и legacy 400 без outcome сохраняют отметку. Reload/возврат не запускает POST и блокирует restore. Известный 400 not-applied/403/404/405 или подтверждённый receipt очищает отметку. Отказ очистки сохраняет блокировку, но не отменяет toast подтверждённого импорта.
|
||||||
|
- Новый импорт требует явного checkbox «Я проверил состояние роутера…» и отдельного действия «Разрешить новое восстановление». Оно только снимает отметку, не отправляет запрос и не объявляет конфигурацию проверенной приложением. Read-only/занятость запрещают снятие; после unknown диалог закрывается кнопкой «Закрыть», это не отмена server operation.
|
||||||
|
- 2 checkpoint/classification unit, 12 operations E2E, все 28 context E2E, TypeScript/профильный ESLint и production frontend прошли. Тесты уточнены: review выполняется после закрытия modal, checkpoint same-origin привязан к location.origin.
|
||||||
|
- Durable backend restore repository отсутствует: отметка действует в текущей вкладке/sessionStorage, не синхронизирует разные браузеры/вкладки и не доказывает результат RouterOS. Реальные RouterOS/PostgreSQL/S3 не проверены; ручная проверка устройства остаётся внешним действием.
|
||||||
|
- **Следующий блок 11А.30:** URL контекст истории/табов/Filters, затем Frame и визуальная адаптивная приёмка, unknown статус устройств, scheduler cleanup и локальная приёмка backups.
|
||||||
|
|
||||||
## Блок 11А.29.4 — неизвестный результат импорта на backend (2026-10-01)
|
## Блок 11А.29.4 — неизвестный результат импорта на backend (2026-10-01)
|
||||||
|
|
||||||
- Import fallback /execute разрешён только при RouterOS HTTP 404/405 для /import. Таймаут/обрыв, невалидный JSON, 400/401/500 не запускают второй импорт. После отправки команды любая ошибка маркируется BackupRestoreUnknownError; HTTP 502/outcome unknown не объявляет конфигурацию неизменённой. Ошибка подготовки до импорта остаётся 400/outcome not-applied.
|
- Import fallback /execute разрешён только при RouterOS HTTP 404/405 для /import. Таймаут/обрыв, невалидный JSON, 400/401/500 не запускают второй импорт. После отправки команды любая ошибка маркируется BackupRestoreUnknownError; HTTP 502/outcome unknown не объявляет конфигурацию неизменённой. Ошибка подготовки до импорта остаётся 400/outcome not-applied.
|
||||||
|
|||||||
@@ -399,3 +399,13 @@
|
|||||||
- Реальный RouterOS не проверен; /execute receipt подтверждает ответ команды, не завершение всех изменений конфигурации. Backend durable restore/idempotency repository пока отсутствует; клиентский запрет слепого повтора нужен следующим блоком.
|
- Реальный RouterOS не проверен; /execute receipt подтверждает ответ команды, не завершение всех изменений конфигурации. Backend durable restore/idempotency repository пока отсутствует; клиентский запрет слепого повтора нужен следующим блоком.
|
||||||
- **Следующий блок 11А.29.5:** checkpoint restore до POST, неизвестный результат/после reload и явная проверка роутера перед новым импортом; затем URL/Frame/адаптивность, scheduler и приёмка backups.
|
- **Следующий блок 11А.29.5:** checkpoint restore до POST, неизвестный результат/после reload и явная проверка роутера перед новым импортом; затем URL/Frame/адаптивность, scheduler и приёмка backups.
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11А.29.5 — отметка неизвестного restore до POST (2026-10-01)
|
||||||
|
|
||||||
|
- До POST записывается immutable backupId/serverId/filename/имя/время в sessionStorage по backend URL. Отказ записи не отправляет импорт. Синхронный guard и общий operation lock защищают повтор; hydration/corrupt checkpoint не допускают начало.
|
||||||
|
- Обрыв, deadline, чужой receipt, backend unknown и legacy 400 без outcome сохраняют отметку. Reload/возврат не запускает POST и блокирует restore. Известный 400 not-applied/403/404/405 или подтверждённый receipt очищает отметку. Отказ очистки сохраняет блокировку, но не отменяет toast подтверждённого импорта.
|
||||||
|
- Новый импорт требует явного checkbox «Я проверил состояние роутера…» и отдельного действия «Разрешить новое восстановление». Оно только снимает отметку, не отправляет запрос и не объявляет конфигурацию проверенной приложением. Read-only/занятость запрещают снятие; после unknown диалог закрывается кнопкой «Закрыть», это не отмена server operation.
|
||||||
|
- 2 checkpoint/classification unit, 12 operations E2E, все 28 context E2E, TypeScript/профильный ESLint и production frontend прошли. Тесты уточнены: review выполняется после закрытия modal, checkpoint same-origin привязан к location.origin.
|
||||||
|
- Durable backend restore repository отсутствует: отметка действует в текущей вкладке/sessionStorage, не синхронизирует разные браузеры/вкладки и не доказывает результат RouterOS. Реальные RouterOS/PostgreSQL/S3 не проверены; ручная проверка устройства остаётся внешним действием.
|
||||||
|
- **Следующий блок 11А.30:** URL контекст истории/табов/Filters, затем Frame и визуальная адаптивная приёмка, unknown статус устройств, scheduler cleanup и локальная приёмка backups.
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { test } from "node:test"
|
||||||
|
import { ApiClientError } from "@/shared/api/http-client"
|
||||||
|
import { backupRestoreStorageKey, readBackupRestoreCheckpoint, restoreWasNotApplied } from "./backup-restore-checkpoint"
|
||||||
|
test("restore checkpoint preserves target and rejects corrupt or incomplete state", () => {
|
||||||
|
const target = { backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "router", requestedAt: "2026-10-01T00:00:00Z" }
|
||||||
|
assert.deepEqual(readBackupRestoreCheckpoint(JSON.stringify(target)), target); assert.equal(readBackupRestoreCheckpoint(null), null)
|
||||||
|
for (const payload of ["invalid JSON", JSON.stringify({}), JSON.stringify({ ...target, serverId: "1" }), JSON.stringify({ ...target, requestedAt: "bad" })]) assert.throws(() => readBackupRestoreCheckpoint(payload))
|
||||||
|
assert.notEqual(backupRestoreStorageKey("first"), backupRestoreStorageKey("second"))
|
||||||
|
})
|
||||||
|
test("unknown response is not proof that import was not applied", () => {
|
||||||
|
for (const error of [new Error("timeout"), new ApiClientError("old backend error", 400), new ApiClientError("lost after apply", 502, { outcome: "unknown" }), new ApiClientError("server error", 500)]) assert.equal(restoreWasNotApplied(error), false)
|
||||||
|
assert.equal(restoreWasNotApplied(new ApiClientError("prepare failed", 400, { outcome: "not-applied" })), true)
|
||||||
|
assert.equal(restoreWasNotApplied(new ApiClientError("forbidden", 403)), true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { ApiClientError } from "@/shared/api/http-client"
|
||||||
|
export const backupRestoreCheckpointSchema = z.object({ backupId: z.string().min(1), serverId: z.number().int().positive().safe(), filename: z.string().min(1), serverName: z.string().min(1), requestedAt: z.string().datetime({ offset: true }) })
|
||||||
|
export type BackupRestoreCheckpoint = z.infer<typeof backupRestoreCheckpointSchema>
|
||||||
|
export function readBackupRestoreCheckpoint(value: string | null) { return value ? backupRestoreCheckpointSchema.parse(JSON.parse(value)) : null }
|
||||||
|
export function backupRestoreStorageKey(backendUrl: string) { return "mmapp:backup-restore:" + backendUrl }
|
||||||
|
export function restoreWasNotApplied(error: unknown): boolean {
|
||||||
|
if (!(error instanceof ApiClientError)) return false
|
||||||
|
if ([403, 404, 405].includes(error.status)) return true
|
||||||
|
return error.status === 400 && !!error.payload && typeof error.payload === "object" && "outcome" in error.payload && error.payload.outcome === "not-applied"
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { backupRestoreCheckpointSchema, backupRestoreStorageKey, readBackupRestoreCheckpoint, type BackupRestoreCheckpoint } from "./backup-restore-checkpoint"
|
||||||
|
export function useBackupRestoreGuard(backendUrl: string, live: boolean) {
|
||||||
|
const [checkpoint, setCheckpoint] = useState<BackupRestoreCheckpoint | null>(null), current = useRef<BackupRestoreCheckpoint | null>(null)
|
||||||
|
const [hydrated, setHydrated] = useState(false), [error, setError] = useState<string | null>(null), blocked = useRef(false), alive = useRef(true)
|
||||||
|
const key = backupRestoreStorageKey(backendUrl)
|
||||||
|
useEffect(() => {
|
||||||
|
alive.current = true
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!alive.current) return
|
||||||
|
try { const saved = live ? readBackupRestoreCheckpoint(sessionStorage.getItem(key)) : null; current.current = saved; setCheckpoint(saved) }
|
||||||
|
catch { blocked.current = true; setError("Не удалось прочитать отметку импорта. Проверьте состояние роутера перед новым восстановлением") }
|
||||||
|
setHydrated(true)
|
||||||
|
})
|
||||||
|
return () => { alive.current = false }
|
||||||
|
}, [key, live])
|
||||||
|
function begin(target: Omit<BackupRestoreCheckpoint, "requestedAt">) {
|
||||||
|
if (!live || !hydrated || !alive.current || blocked.current || current.current) return false
|
||||||
|
const value = backupRestoreCheckpointSchema.parse({ ...target, requestedAt: new Date().toISOString() })
|
||||||
|
try { sessionStorage.setItem(key, JSON.stringify(value)) }
|
||||||
|
catch { blocked.current = true; setError("Не удалось сохранить отметку импорта. Запрос не отправлен"); return false }
|
||||||
|
current.current = value; setCheckpoint(value); return true
|
||||||
|
}
|
||||||
|
function clear() {
|
||||||
|
if (!live || !hydrated || !alive.current) return false
|
||||||
|
try { sessionStorage.removeItem(key) }
|
||||||
|
catch { blocked.current = true; setError("Не удалось очистить отметку импорта. Новый импорт заблокирован"); return false }
|
||||||
|
current.current = null; blocked.current = false; setCheckpoint(null); setError(null); return true
|
||||||
|
}
|
||||||
|
return { checkpoint, hydrated, error, begin, clear, canStart: hydrated && !checkpoint && !error }
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ for (const status of [403, 503, 200]) test("Бэкапы: удаление " + s
|
|||||||
})
|
})
|
||||||
test("Бэкапы: ошибка restore и чужой filename сохраняют диалог", async ({ page }) => {
|
test("Бэкапы: ошибка restore и чужой filename сохраняют диалог", async ({ page }) => {
|
||||||
await setupBackups(page); let failure = true
|
await setupBackups(page); let failure = true
|
||||||
await page.route("**/api/backups/backup-1/restore", r => r.fulfill(failure ? { status: 503, json: { error: "Импорт отказал" } } : { json: { backupId: "backup-1", serverId: 1, filename: "other.rsc", serverName: "router" } }))
|
await page.route("**/api/backups/backup-1/restore", r => r.fulfill(failure ? { status: 400, json: { error: "Импорт отказал", outcome: "not-applied" } } : { json: { backupId: "backup-1", serverId: 1, filename: "other.rsc", serverName: "router" } }))
|
||||||
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
|
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
|
||||||
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click()
|
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click()
|
||||||
await expect(dialog.getByRole("alert").filter({ hasText: "Импорт отказал" })).toBeVisible()
|
await expect(dialog.getByRole("alert").filter({ hasText: "Импорт отказал" })).toBeVisible()
|
||||||
@@ -44,6 +44,47 @@ test("Бэкапы: restore требует правильный serverId и пр
|
|||||||
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
|
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
|
||||||
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click()
|
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click()
|
||||||
await expect(dialog.getByRole("alert").filter({ hasText: "другой резервной копии или серверу" })).toBeVisible()
|
await expect(dialog.getByRole("alert").filter({ hasText: "другой резервной копии или серверу" })).toBeVisible()
|
||||||
serverId = 1; await dialog.getByRole("button", { name: "Восстановить", exact: true }).dblclick()
|
await expect(dialog.getByRole("button", { name: "Восстановить", exact: true })).toBeDisabled()
|
||||||
|
await dialog.getByRole("button", { name: "Закрыть", exact: true }).click()
|
||||||
|
await page.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await page.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click()
|
||||||
|
serverId = 1; await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click(); await dialog.getByRole("button", { name: "Восстановить", exact: true }).dblclick()
|
||||||
await expect(dialog).toBeHidden(); await expect(page.getByText("API подтвердил импорт " + backup.filename + " на Новое имя роутера", { exact: true })).toBeVisible(); expect(calls).toBe(2)
|
await expect(dialog).toBeHidden(); await expect(page.getByText("API подтвердил импорт " + backup.filename + " на Новое имя роутера", { exact: true })).toBeVisible(); expect(calls).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const outcome of ["unknown", "legacy-error"]) test("Бэкапы: неизвестный restore переживает reload/" + outcome, async ({ page }) => {
|
||||||
|
await setupBackups(page); let calls = 0
|
||||||
|
await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ status: outcome === "unknown" ? 502 : 400, json: { error: "Ответ импорта потерян", ...(outcome === "unknown" ? { outcome: "unknown" } : {}) } }) })
|
||||||
|
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
|
||||||
|
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Восстановить", exact: true }).click()
|
||||||
|
await expect(dialog.getByRole("button", { name: "Восстановить", exact: true })).toBeDisabled(); await expect(dialog).toContainText("Результат импорта неизвестен")
|
||||||
|
await page.reload(); const alert = page.getByRole("alert", { name: "Подтверждение восстановления", exact: true })
|
||||||
|
await expect(alert).toContainText(backup.filename); await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled()
|
||||||
|
await expect(alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true })).toBeDisabled(); expect(calls).toBe(1)
|
||||||
|
await alert.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click()
|
||||||
|
await expect(alert).toBeHidden(); await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeEnabled(); expect(calls).toBe(1)
|
||||||
|
})
|
||||||
|
test("Бэкапы: запрет записи checkpoint не отправляет restore", async ({ page }) => {
|
||||||
|
await setupBackups(page); let calls = 0
|
||||||
|
await page.addInitScript(() => { const original = Storage.prototype.setItem; Storage.prototype.setItem = function(key, value) { if (key.startsWith("mmapp:backup-restore:")) throw new Error("storage denied"); return original.call(this, key, value) } })
|
||||||
|
await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ json: {} }) })
|
||||||
|
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
|
||||||
|
await page.getByRole("alertdialog").getByRole("button", { name: "Восстановить", exact: true }).click()
|
||||||
|
await expect(page.getByRole("alert", { name: "Подтверждение восстановления", exact: true })).toContainText("Запрос не отправлен"); expect(calls).toBe(0)
|
||||||
|
})
|
||||||
|
test("Бэкапы: повреждённый checkpoint запрещает новый restore", async ({ page }) => {
|
||||||
|
await setupBackups(page); await page.addInitScript(() => sessionStorage.setItem("mmapp:backup-restore:" + location.origin, "broken"))
|
||||||
|
await page.goto("/backups"); await expect(page.getByRole("alert", { name: "Подтверждение восстановления", exact: true })).toContainText("Не удалось прочитать отметку")
|
||||||
|
await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Бэкапы: подтверждённый restore с отказом очистки checkpoint не допускает новый импорт", async ({ page }) => {
|
||||||
|
await setupBackups(page); let calls = 0
|
||||||
|
await page.addInitScript(() => { const original = Storage.prototype.removeItem; Storage.prototype.removeItem = function(key) { if (key.startsWith("mmapp:backup-restore:")) throw new Error("storage denied"); return original.call(this, key) } })
|
||||||
|
await page.route("**/api/backups/backup-1/restore", async r => { calls++; await r.fulfill({ json: { backupId: backup.id, serverId: backup.serverId, filename: backup.filename, serverName: "router" } }) })
|
||||||
|
await page.goto("/backups"); await page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }).click()
|
||||||
|
await page.getByRole("alertdialog").getByRole("button", { name: "Восстановить", exact: true }).click()
|
||||||
|
await expect(page.getByRole("alertdialog")).toBeHidden(); await expect(page.getByText("API подтвердил импорт " + backup.filename + " на router", { exact: true })).toBeVisible()
|
||||||
|
const alert = page.getByRole("alert", { name: "Подтверждение восстановления", exact: true }); await expect(alert).toContainText("Не удалось очистить отметку")
|
||||||
|
await alert.getByRole("checkbox", { name: /Я проверил состояние роутера/ }).check(); await alert.getByRole("button", { name: "Разрешить новое восстановление", exact: true }).click()
|
||||||
|
await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled(); expect(calls).toBe(1)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user