diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index d49454c0..1b0d1d96 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -44,6 +44,7 @@ import { useBackupJob } from "@/lib/use-backup-job" import { backupJobOutcome, backupJobResultError } from "@/lib/backup-job-poll" import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings" import { BackupsHistory } from "@/components/backups/backups-history" +import { backupFileMissing } from "@/lib/backup-file-state" import { BackupsSettings, WEEK_DAYS, @@ -148,6 +149,7 @@ function BackupsContent() { notes: b.notes ?? "", storage: b.storage, uploadError: b.uploadError ?? undefined, + localFileStatus: b.localFileStatus, } }, []) @@ -304,7 +306,7 @@ function BackupsContent() { if (!deleteTarget) return const target = deleteTarget, abort = startOperation(); if (!abort) return try { - await deleteBackup(backendUrl, target.id, { signal: abort.signal }) + await deleteBackup(backendUrl, target.id, { signal: abort.signal }, backupFileMissing(target)) if (!alive.current || abort.signal.aborted) return setBackupList(previous => previous.filter(backup => backup.id !== target.id)); setDeleteTarget(null) toast.success("API подтвердил удаление резервной копии") @@ -314,6 +316,10 @@ function BackupsContent() { } async function handleRestore() { if (!restoreTarget || !restoreTarget.serverId || !restoreGuard.canStart) return + if (backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget)) { + setOpError("Локальный файл отсутствует. Создайте новую резервную копию.") + return + } const target = restoreTarget, abort = startOperation(); if (!abort) return let sent = false try { @@ -607,8 +613,8 @@ function BackupsContent() { returnFocus={returnDialogFocus} backup={restoreTarget} busy={opBusy} - blocked={!restoreGuard.canStart} - error={opError} + blocked={!restoreGuard.canStart || Boolean(restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget))} + error={restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget) ? "Локальный файл отсутствует. Создайте новую резервную копию." : opError} onConfirm={() => void handleRestore()} onCancel={() => setRestoreTarget(null)} /> diff --git a/backend/package.json b/backend/package.json index deaae921..d3ede88d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,7 +27,7 @@ "test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts", "test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts", "test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts", - "test:backups": "tsx src/services/router-config-export.test.ts && tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", + "test:backups": "tsx src/services/backup-local-file-state.test.ts && tsx src/services/router-config-export.test.ts && tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", "test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts && tsx src/routes/ipsec-nat.test.ts && tsx src/routes/ipsec-group.test.ts", "test:live-maps": "tsx src/services/gre-keepalive.test.ts && tsx src/services/gre-read.test.ts && tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts && tsx src/routes/ospf-legacy.test.ts", "test": "node scripts/test.mjs", diff --git a/backend/scripts/acceptance/backup-availability.mjs b/backend/scripts/acceptance/backup-availability.mjs new file mode 100644 index 00000000..e346ff4f --- /dev/null +++ b/backend/scripts/acceptance/backup-availability.mjs @@ -0,0 +1,3 @@ +import assert from'node:assert/strict';import fs from'node:fs';import{createRequire}from'node:module';import{createServer}from'node:http'; +const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database); +const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool,db}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');let nativeRequests=0;const native=createServer((_req,res)=>{nativeRequests++;res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));const[server]=await db.insert(servers).values({name:'owned-availability237',host:'127.0.0.1',port:native.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();fs.mkdirSync('storage/backups',{recursive:true});fs.writeFileSync('storage/backups/available237.rsc','# harmless own fixture\n');fs.mkdirSync('storage/backups/directory237.rsc');const fixtures=[['available237','available237.rsc','local',null],['missing237','missing237.rsc','local',null],['both237','both237.rsc','both','own-test/both237.rsc'],['remote237','remote237.rsc','s3','own-test/remote237.rsc'],['directory237','directory237.rsc','local',null]];for(const[id,filename,storage,key]of fixtures)await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES($1,$2,$3,$4,1,$5,$6,$7,now())',[id,server.id,'fixture',filename,'manual',storage,key]);const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const report={date:'2026-10-14',block:'12.5.237',passed:false,checks:[],externalS3Contacts:0,chrContacts:0};try{let r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.statusCode,200);const statuses=Object.fromEntries(r.json().map(r=>[r.id,r.localFileStatus]));assert.deepEqual(statuses,{available237:'available',missing237:'missing',both237:'missing',remote237:'not-stored',directory237:'unknown'});report.checks.push({listStates:statuses});r=await app.inject({method:'GET',url:'/api/backups/available237/download'});assert.equal(r.statusCode,200);assert.equal(r.body,'# harmless own fixture\n');report.checks.push({availableDownload:200});r=await app.inject({method:'GET',url:'/api/backups/missing237/download'});assert.equal(r.statusCode,404);report.checks.push({missingDownload:404});r=await app.inject({method:'POST',url:'/api/backups/missing237/restore'});assert.equal(r.statusCode,400);assert.equal(r.json().outcome,'not-applied');assert.match(r.json().error,/Локальный файл/);assert.equal(nativeRequests,0);report.checks.push({missingRestore:400,outcome:'not-applied',nativeRequests:0});r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.json().length,5);report.checks.push({failurePreservesCatalog:true});fs.writeFileSync('storage/backups/missing237.rsc','# recreated fixture\n');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,409);assert(fs.existsSync('storage/backups/missing237.rsc'));assert.equal((await pool.query("SELECT count(*)::int AS n FROM backup_entries WHERE id='missing237'")).rows[0].n,1);report.checks.push({recreatedFilePreserved:409});fs.unlinkSync('storage/backups/missing237.rsc');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,204);assert.equal(r.headers['x-backup-delete-mode'],'record-only');report.checks.push({missingRecordDeletion:204,mode:'record-only'});report.passed=true}catch(e){report.failure={name:e.name}}finally{await databaseBackgroundWork.drain();await app.close();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/checks/backup-availability237.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1} diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts index 865da2c6..7e3f21b1 100644 --- a/backend/src/routes/backups.ts +++ b/backend/src/routes/backups.ts @@ -215,10 +215,17 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise { - const hit = await (deps.delete ?? deleteBackupRecord)(req.params.id) - if (!hit) return reply.status(404).send({ error: "Бэкап не найден" }) - return reply.status(204).send() + app.delete("/backups/:id", { schema: { params: BackupIdParamSchema, querystring: z.object({ recordOnly: z.enum(["true", "false"]).optional() }) } }, async (req, reply) => { + try { + const recordOnly = req.query.recordOnly === "true" + const hit = await (deps.delete ?? deleteBackupRecord)(req.params.id, { recordOnly }) + if (!hit) return reply.status(404).send({ error: "Бэкап не найден" }) + reply.header("X-Backup-Delete-Mode", recordOnly ? "record-only" : "file-and-record") + return reply.status(204).send() + } catch (error) { + if (error instanceof BackupStorageConflictError) return reply.status(409).send({ error: error.message }) + throw error + } }) } } diff --git a/backend/src/services/backup-local-file-state.test.ts b/backend/src/services/backup-local-file-state.test.ts new file mode 100644 index 00000000..301cbbce --- /dev/null +++ b/backend/src/services/backup-local-file-state.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict" +import { mkdtemp, writeFile, mkdir, rm, lstat } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { withBackupLocalFileStates } from "./backup-local-file-state.js" +import type { BackupItemDto } from "@mmapp/contracts/backups" + +const directory = await mkdtemp(path.join(os.tmpdir(), "mm-backup-state-")) +const row = (filename: string, storage: BackupItemDto["storage"] = "local"): BackupItemDto => ({ id: filename, filename, serverId: 1, serverName: "fixture", sizeBytes: 0, createdAt: "2026-10-14T00:00:00Z", kind: "manual", storage }) +try { + await writeFile(path.join(directory, "available.rsc"), "# own fixture\n") + await mkdir(path.join(directory, "directory.rsc")) + const rows = [row("available.rsc"), row("missing.rsc"), row("missing-both.rsc", "both"), row("remote.rsc", "s3"), row("directory.rsc"), row("../outside.rsc")] + const result = await withBackupLocalFileStates(rows, directory) + assert.deepEqual(result.map(r => r.localFileStatus), ["available", "missing", "missing", "not-stored", "unknown", "unknown"]) + assert(rows.every(r => r.localFileStatus === undefined)) + assert.equal((await lstat(path.join(directory, "available.rsc"))).isFile(), true) + const denied = await withBackupLocalFileStates([row("denied.rsc")], directory, (async () => { throw Object.assign(new Error("denied"), { code: "EACCES" }) }) as typeof lstat) + assert.equal(denied[0].localFileStatus, "unknown") + let active = 0, max = 0 + const inspect = (async () => { active++; max = Math.max(max, active); await new Promise(r => setTimeout(r, 1)); active--; return lstat(path.join(directory, "available.rsc")) }) as typeof lstat + const many = await withBackupLocalFileStates(Array.from({ length: 100 }, (_, i) => row(`${i}.rsc`)), directory, inspect) + assert.equal(many.length, 100); assert(max <= 8); assert.equal(active, 0) + let touched = false + await withBackupLocalFileStates([row("remote-only.rsc", "s3"), row("../outside.rsc")], directory, (async () => { touched = true; throw Error("unexpected") }) as typeof lstat) + assert.equal(touched, false) + console.log("backup local states: real file/missing/both/remote/directory/path, permission error and bounded100 passed") +} finally { await rm(directory, { recursive: true, force: true }) } diff --git a/backend/src/services/backup-local-file-state.ts b/backend/src/services/backup-local-file-state.ts new file mode 100644 index 00000000..4d41ad5f --- /dev/null +++ b/backend/src/services/backup-local-file-state.ts @@ -0,0 +1,28 @@ +import { lstat } from "node:fs/promises" +import type { BackupItemDto } from "@mmapp/contracts/backups" +import { backupDeletionPath } from "./backup-deletion.js" + +/** Inspect local copies without probing S3; preserve unknown states and bound filesystem work. */ +export async function withBackupLocalFileStates( + rows: T[], directory: string, inspect = lstat, +): Promise { + const result = [...rows] + let cursor = 0 + await Promise.all(Array.from({ length: Math.min(8, rows.length) }, async () => { + while (cursor < rows.length) { + const index = cursor++, row = rows[index] + let status: BackupItemDto["localFileStatus"] = "not-stored" + if (row.storage !== "s3") { + try { + const info = await inspect(backupDeletionPath(directory, row.filename)) + status = info.isFile() ? "available" : "unknown" + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + status = code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown" + } + } + result[index] = { ...row, localFileStatus: status } + } + })) + return result +} diff --git a/backend/src/services/backup-service.ts b/backend/src/services/backup-service.ts index 6b6fd845..298b0aa3 100644 --- a/backend/src/services/backup-service.ts +++ b/backend/src/services/backup-service.ts @@ -6,13 +6,15 @@ import type { BackupScheduleSettingsDto, BackupStorageSettingsDto, PutBackupStorageSettings, + BackupLocalFileStatus, } from "@mmapp/contracts/backups" import { mapBackupEntry } from "./backup-dto.js" import { pruneBackupRecords } from "./backup-prune.js" import { assertRestorableBackupScript, importBackupOnce } from "./backup-restore.js" -import { deleteStoredBackup } from "./backup-deletion.js" +import { backupDeletionPath, deleteStoredBackup } from "./backup-deletion.js" +import { withBackupLocalFileStates } from "./backup-local-file-state.js" import { assertBackupStorageConnectionChange, planBackupStorageUpdate } from "./backup-storage-update.js" -import { withBackupStorageOperation, withBackupStorageSettingsUpdate } from "./backup-storage-operation.js" +import { BackupStorageConflictError, withBackupStorageOperation, withBackupStorageSettingsUpdate } from "./backup-storage-operation.js" import { db } from "../db/index.js" import { parseJsonArray } from "../db/json.js" import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js" @@ -47,6 +49,7 @@ export type BackupMeta = { storage: "local" | "s3" | "both" s3Key?: string | null uploadError?: string | null + localFileStatus?: BackupLocalFileStatus } type BackupRow = typeof backupEntries.$inferSelect @@ -59,12 +62,12 @@ export async function ensureBackupStorage(): Promise { export async function listBackups(): Promise { const rows = await db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt)) - return rows.map(rowToMeta) + return withBackupLocalFileStates(rows.map(rowToMeta), BACKUPS_DIR) } export async function getBackupById(id: string): Promise { const row = (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0] - return row ? rowToMeta(row) : null + return row ? (await withBackupLocalFileStates([rowToMeta(row)], BACKUPS_DIR))[0] : null } export async function insertBackup(meta: BackupMeta): Promise { @@ -92,14 +95,23 @@ async function getBackupRow(id: string): Promise { return (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0] } -export async function deleteBackupRecord(id: string): Promise { - return withBackupStorageOperation(() => deleteStoredBackupRecord(id)) +export async function deleteBackupRecord(id: string, options: { recordOnly?: boolean } = {}): Promise { + return withBackupStorageOperation(() => deleteStoredBackupRecord(id, Boolean(options.recordOnly))) } -async function deleteStoredBackupRecord(id: string): Promise { +async function deleteStoredBackupRecord(id: string, recordOnly: boolean): Promise { const row = await getBackupRow(id) if (!row) return null const meta = rowToMeta(row) + if (recordOnly) { + const [current] = await withBackupLocalFileStates([meta], BACKUPS_DIR) + if (row.storage !== "local" || row.s3Key || current.localFileStatus !== "missing") { + throw new BackupStorageConflictError("Файл больше не подтверждён отсутствующим. Обновите историю перед удалением записи.") + } + // Never remove a file in this mode, even if an external writer recreates it after the check. + await db.delete(backupEntries).where(eq(backupEntries.id, id)) + return meta + } await deleteStoredBackup(row, BACKUPS_DIR, { removeS3: async key => { const cfg = await getS3ConfigIfEnabled() @@ -481,6 +493,7 @@ export async function readBackupContent(meta: BackupMeta): Promise { } async function readStoredBackupContent(meta: BackupMeta): Promise { + let remoteUnavailable = false if ((meta.storage === "s3" || meta.storage === "both") && meta.s3Key) { try { const cfg = await getS3ConfigIfEnabled() @@ -488,11 +501,21 @@ async function readStoredBackupContent(meta: BackupMeta): Promise { const client = createS3ClientFromConfig(cfg) return await s3GetObject(client, cfg.bucket, meta.s3Key) } + remoteUnavailable = true } catch { + remoteUnavailable = true /* fallback: локальная копия, если есть */ } } - return await readFile(path.join(BACKUPS_DIR, meta.filename)) + try { + return await readFile(backupDeletionPath(BACKUPS_DIR, meta.filename)) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === "ENOENT" || code === "ENOTDIR") throw new Error(remoteUnavailable + ? "Файл недоступен в S3, локальная копия отсутствует. Проверьте подключение к хранилищу." + : "Локальный файл резервной копии отсутствует. Создайте новую копию или восстановите файл в хранилище.") + throw error + } } export async function restoreBackupToDevice(id: string): Promise<{ backupId: string; serverId: number; filename: string; serverName: string }> { diff --git a/components/backups/backup-confirm-dialogs.tsx b/components/backups/backup-confirm-dialogs.tsx index 270096c1..c2e61b32 100644 --- a/components/backups/backup-confirm-dialogs.tsx +++ b/components/backups/backup-confirm-dialogs.tsx @@ -13,6 +13,7 @@ import { } from "@/components/ui/alert-dialog" import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert" import type { Backup } from "@/lib/data" +import { backupFileMissing } from "@/lib/backup-file-state" import { AlertCircleIcon, LoaderCircleIcon, TriangleAlertIcon } from "lucide-react" export function BackupDeleteDialog({ @@ -37,10 +38,10 @@ export function BackupDeleteDialog({ - Удалить бэкап? + {backup && backupFileMissing(backup) ? "Удалить запись о бэкапе?" : "Удалить бэкап?"} - Файл {backup?.filename} будет удалён - {backup?.storage === "s3" || backup?.storage === "both" ? " локально; объект S3 — через текущее подключение к хранилищу" : " с диска"}. + {backup && backupFileMissing(backup) ? <>Локальный файл {backup.filename} отсутствует. Будет удалена только запись из истории. : <>Файл {backup?.filename} будет удалён + {backup?.storage === "s3" || backup?.storage === "both" ? " локально; объект S3 — через текущее подключение к хранилищу" : " с диска"}.} {error &&

{error}

} diff --git a/components/data-grids/backups-data-grid.tsx b/components/data-grids/backups-data-grid.tsx index b5d38680..ea7ff42e 100644 --- a/components/data-grids/backups-data-grid.tsx +++ b/components/data-grids/backups-data-grid.tsx @@ -8,6 +8,7 @@ import { useTable, } from "@tanstack/react-table" import type { Backup } from "@/lib/data" +import { backupFileMissing, backupLocalFileDescription } from "@/lib/backup-file-state" import { Button } from "@/components/ui/button" import { Badge } from "@/components/reui/badge" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" @@ -70,6 +71,7 @@ function BackupsDataGrid({
{b.filename} + {backupLocalFileDescription(b) && {backupLocalFileDescription(b)}} {b.uploadError ? ( {b.uploadError} ) : null} @@ -134,7 +136,12 @@ function BackupsDataGrid({ accessorKey: "storage", header: ({ column }) => , cell: ({ row }) => { - const badge = storageBadge(row.original.storage) + const backup = row.original + const badge = backupFileMissing(backup) + ? { label: "Нет файла", variant: "warning-light" as const } + : backup.localFileStatus === "missing" ? { label: "S3", variant: "warning-light" as const } + : backup.localFileStatus === "unknown" ? { label: "Файл не проверен", variant: "warning-light" as const } + : storageBadge(backup.storage) return ( {badge.label} @@ -187,9 +194,10 @@ function BackupsDataGrid({ variant="ghost" size="icon" className="size-11 min-w-[44px] min-h-[44px] sm:size-7 sm:min-w-0 sm:min-h-0" - title="Скачать" + title={backupFileMissing(b) ? "Локальный файл отсутствует" : "Скачать"} aria-label={`Скачать ${b.filename}`} - disabled={!canDownload} + disabled={!canDownload || backupFileMissing(b)} + aria-describedby={backupLocalFileDescription(b) ? `backup-file-${encodeURIComponent(b.id)}` : undefined} onClick={() => onDownload(b.id, b.filename)} > @@ -199,9 +207,10 @@ function BackupsDataGrid({ variant="ghost" size="icon" className="size-11 min-w-[44px] min-h-[44px] sm:size-7 sm:min-w-0 sm:min-h-0" - title="Восстановить" + title={backupFileMissing(b) ? "Локальный файл отсутствует" : "Восстановить"} aria-label={`Восстановить ${b.filename}`} - disabled={!canWrite || !canRestore || !b.serverId} + disabled={!canWrite || !canRestore || !b.serverId || backupFileMissing(b)} + aria-describedby={backupLocalFileDescription(b) ? `backup-file-${encodeURIComponent(b.id)}` : undefined} onClick={() => onRestore(b)} > diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index 57f8382c..96afc087 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -1,6 +1,6 @@ # UX/UI: протокол внешней приёмки 12.5 -Актуальное продолжение 2026-10-14:236 — по приоритету пользователя проверен REST:384 paired samples из workstation/Debian +40 diagnostics, отказов0; current credentials/policies/addresses/service load исправны. На7.24.5 сохранены828/304 rest-api sessions и2 api на каждом; после3 дополнительных reads delta0. Найден vendor fix inactive REST sessions в7.25rc, это гипотеза, причинность401/500 не доказана. Runtime ux-backup234/ux-routing218 unchanged. Следующий REST шаг — корреляция сбоя/renewal или согласованный firmware control; общий следующий237 — missing local files UX. Этап12.5 открыт. +Актуальное продолжение 2026-10-14:237 — история бэкапов показывает подтверждённое отсутствие локального файла; скачивание/restore заблокированы, удаление только записи требует отдельного режима и receipt. Production SQL/API, 39 уникальных E2E и live UI ПК/телефон приняты в записанных границах. Backend/frontend ux-backups237; исходные10 строк каталога сохранены без изменений. Следующий238 — длительное REST-наблюдение; затем S3 restore через UI и retention. Этап12.5 открыт. IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md). @@ -18,6 +18,24 @@ OSPF84: real GRE Full adjacency/private routes/Ping3/3 и4 live PC/320px URL/bro BFD85: Up/Full/own route/Ping,4 live UI и loss/withdrawal/recovery приняты; exact own cleanup8 коллекций×2 passed. Withdrawal5,245ms не подтверждает sub-second BFD timing; этот вопрос открыт. Runtime routing83 без новой сборки. [Отчёт](ux-ui/acceptance/ospf-bfd-loss-2026-10-09.json). +## 12.5.237 — отсутствующие файлы бэкапов и безопасное удаление записи (2026-10-14) + +API добавляет необязательный localFileStatus: available/missing/unknown/not-stored. Локальные проверки ограничены восемью workers и не опрашивают S3. Ошибка доступа/каталог/некорректный путь не превращаются в «файл отсутствует». Для local/missing существующая таблица ReUI показывает «Нет файла», описание и блокирует скачивание/restore. Для both/missing остаётся запрос S3; состояние unknown и старый DTO не блокируются по догадке. Проверка наличия файла перед импортом остаётся обязательной на backend. + +Подтверждение удаления отсутствующего локального файла обещает только удаление строки истории. UI отправляет recordOnly=true и требует HTTP204 с X-Backup-Delete-Mode:record-only. Backend повторно проверяет missing/local/no-S3; появившийся файл даёт409 с сохранением файла и каталога. В этом режиме unlink никогда не вызывается, включая внешнее появление файла после проверки. Общий gate обеспечивает координацию одного процесса; несколько writers/instances остаются отдельной приёмкой. + +Contracts/backend build, TypeScript и scoped ESLint прошли. test:backups прошёл после разрешённого запуска локальных HTTP-фикстур: первоначальный sandbox EACCES127.0.0.1 не был ошибкой приложения. E2E:37 исходных сценариев file/data/operations/storage и2 новых record-only receipt; после поздней защиты повторены20 затронутых сценариев. Итого39 уникальных, повторные запуски не прибавлены. Production owned SQL/API:5 статусов, download200/404, restore400/not-applied/nativeRequests0, сохранение каталога, recreated-file409 и record-only204/header. Собственные DB/role/container удалены; S3/CHR из этой SQL-фикстуры не опрашивались. + +Production backend/frontend ux-backups237 собраны и развёрнуты:508 backend source/1068 compiled и529 frontend source совпали с локальными манифестами. Реальный Portal login и UI1440/390 показали10 существующих local/missing записей, disabled download/restore, корректное описание удаления (только открытие и отмена), pageerrors0/backupwrites0/globaloverflow0. Исходные10 SQL-строк сравнивались целиком до/после; ни одна не удалена. Отсутствующие старые файлы не восстановлены и не объявлены доступными. Приватный SQL-снимок удалён после сверки. Откат: backend ux-backup234/frontend ux-routing218 сохранены. + +Доказательства: [SQL/API](ux-ui/acceptance/backup-availability237.json), [live UI](ux-ui/acceptance/backups237-live-ui.json), [production сверка](ux-ui/acceptance/backups237-deployment.json). Снимки: [ПК](ux-ui/screens/backups237-missing-desktop.png), [телефон](ux-ui/screens/backups237-missing-mobile.png). + +| Референс | Схема доработки | +| --- | --- | +| [ReUI Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | История → статус файла → доступные действия → подтверждённый результат; missing → только запись → проверка409/receipt204 | + +Следующий238 фиксирует уже выполненное длительное REST-наблюдение. Затем — S3 restore через реальный UI в пределах собственного bridge и retention. Полная конфигурация CHR, arbitrary PKI, другие ОС, public IPv6, multi-instance и внешние jobs не приняты этим блоком. + ## Паспорт стенда Перед внешним запуском зафиксировать отдельные PostgreSQL и RouterOS тестовые устройства, версии server/client PostgreSQL и RouterOS, HEAD backend/frontend, роли пользователей, число экземпляров backend/планировщика/отправителей и способ их остановки. Для Telegram использовать тестовый чат, для остальных интеграций — тестовые endpoints и учётные записи. Зафиксировать ожидаемые изменения и способ восстановления стенда. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 5352f153..a214c1c0 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,9 +1,27 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-14:236 — по приоритету пользователя проверен REST:384 paired samples из workstation/Debian +40 diagnostics, отказов0; current credentials/policies/addresses/service load исправны. На7.24.5 сохранены828/304 rest-api sessions и2 api на каждом; после3 дополнительных reads delta0. Найден vendor fix inactive REST sessions в7.25rc, это гипотеза, причинность401/500 не доказана. Runtime ux-backup234/ux-routing218 unchanged. Следующий REST шаг — корреляция сбоя/renewal или согласованный firmware control; общий следующий237 — missing local files UX. Этап12.5 открыт. +Актуальное продолжение 2026-10-14:237 — история бэкапов показывает подтверждённое отсутствие локального файла; скачивание/restore заблокированы, удаление только записи требует отдельного режима и receipt. Production SQL/API, 39 уникальных E2E и live UI ПК/телефон приняты в записанных границах. Backend/frontend ux-backups237; исходные10 строк каталога сохранены без изменений. Следующий238 — длительное REST-наблюдение; затем S3 restore через UI и retention. Этап12.5 открыт. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-14. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. +## 12.5.237 — отсутствующие файлы бэкапов и безопасное удаление записи (2026-10-14) + +API добавляет необязательный localFileStatus: available/missing/unknown/not-stored. Локальные проверки ограничены восемью workers и не опрашивают S3. Ошибка доступа/каталог/некорректный путь не превращаются в «файл отсутствует». Для local/missing существующая таблица ReUI показывает «Нет файла», описание и блокирует скачивание/restore. Для both/missing остаётся запрос S3; состояние unknown и старый DTO не блокируются по догадке. Проверка наличия файла перед импортом остаётся обязательной на backend. + +Подтверждение удаления отсутствующего локального файла обещает только удаление строки истории. UI отправляет recordOnly=true и требует HTTP204 с X-Backup-Delete-Mode:record-only. Backend повторно проверяет missing/local/no-S3; появившийся файл даёт409 с сохранением файла и каталога. В этом режиме unlink никогда не вызывается, включая внешнее появление файла после проверки. Общий gate обеспечивает координацию одного процесса; несколько writers/instances остаются отдельной приёмкой. + +Contracts/backend build, TypeScript и scoped ESLint прошли. test:backups прошёл после разрешённого запуска локальных HTTP-фикстур: первоначальный sandbox EACCES127.0.0.1 не был ошибкой приложения. E2E:37 исходных сценариев file/data/operations/storage и2 новых record-only receipt; после поздней защиты повторены20 затронутых сценариев. Итого39 уникальных, повторные запуски не прибавлены. Production owned SQL/API:5 статусов, download200/404, restore400/not-applied/nativeRequests0, сохранение каталога, recreated-file409 и record-only204/header. Собственные DB/role/container удалены; S3/CHR из этой SQL-фикстуры не опрашивались. + +Production backend/frontend ux-backups237 собраны и развёрнуты:508 backend source/1068 compiled и529 frontend source совпали с локальными манифестами. Реальный Portal login и UI1440/390 показали10 существующих local/missing записей, disabled download/restore, корректное описание удаления (только открытие и отмена), pageerrors0/backupwrites0/globaloverflow0. Исходные10 SQL-строк сравнивались целиком до/после; ни одна не удалена. Отсутствующие старые файлы не восстановлены и не объявлены доступными. Приватный SQL-снимок удалён после сверки. Откат: backend ux-backup234/frontend ux-routing218 сохранены. + +Доказательства: [SQL/API](ux-ui/acceptance/backup-availability237.json), [live UI](ux-ui/acceptance/backups237-live-ui.json), [production сверка](ux-ui/acceptance/backups237-deployment.json). Снимки: [ПК](ux-ui/screens/backups237-missing-desktop.png), [телефон](ux-ui/screens/backups237-missing-mobile.png). + +| Референс | Схема доработки | +| --- | --- | +| [ReUI Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | История → статус файла → доступные действия → подтверждённый результат; missing → только запись → проверка409/receipt204 | + +Следующий238 фиксирует уже выполненное длительное REST-наблюдение. Затем — S3 restore через реальный UI в пределах собственного bridge и retention. Полная конфигурация CHR, arbitrary PKI, другие ОС, public IPv6, multi-instance и внешние jobs не приняты этим блоком. + ## Актуальная точка продолжения — 2026-10-14 Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка201 и Portal SSO195–199 приняты в описанных границах. Multi-instance, внешние аккаунты/клиентские ОС и оставшиеся профильные сетевые ограничения открыты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 23fdca6a..b7fe0db6 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-14:236 — по приоритету пользователя проверен REST:384 paired samples из workstation/Debian +40 diagnostics, отказов0; current credentials/policies/addresses/service load исправны. На7.24.5 сохранены828/304 rest-api sessions и2 api на каждом; после3 дополнительных reads delta0. Найден vendor fix inactive REST sessions в7.25rc, это гипотеза, причинность401/500 не доказана. Runtime ux-backup234/ux-routing218 unchanged. Следующий REST шаг — корреляция сбоя/renewal или согласованный firmware control; общий следующий237 — missing local files UX. Этап12.5 открыт. +Актуальное продолжение 2026-10-14:237 — история бэкапов показывает подтверждённое отсутствие локального файла; скачивание/restore заблокированы, удаление только записи требует отдельного режима и receipt. Production SQL/API, 39 уникальных E2E и live UI ПК/телефон приняты в записанных границах. Backend/frontend ux-backups237; исходные10 строк каталога сохранены без изменений. Следующий238 — длительное REST-наблюдение; затем S3 restore через UI и retention. Этап12.5 открыт. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -38,6 +38,24 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка12.3.83 в границах журнала. Внешняя12.5: блоки1–236 завершены в записанных scopes, этап открыт. Runtime backend ux-backup234/frontend ux-routing218; backend25/UI52 и дополнительные SQL/UDP222 прошли. | +## 12.5.237 — отсутствующие файлы бэкапов и безопасное удаление записи (2026-10-14) + +API добавляет необязательный localFileStatus: available/missing/unknown/not-stored. Локальные проверки ограничены восемью workers и не опрашивают S3. Ошибка доступа/каталог/некорректный путь не превращаются в «файл отсутствует». Для local/missing существующая таблица ReUI показывает «Нет файла», описание и блокирует скачивание/restore. Для both/missing остаётся запрос S3; состояние unknown и старый DTO не блокируются по догадке. Проверка наличия файла перед импортом остаётся обязательной на backend. + +Подтверждение удаления отсутствующего локального файла обещает только удаление строки истории. UI отправляет recordOnly=true и требует HTTP204 с X-Backup-Delete-Mode:record-only. Backend повторно проверяет missing/local/no-S3; появившийся файл даёт409 с сохранением файла и каталога. В этом режиме unlink никогда не вызывается, включая внешнее появление файла после проверки. Общий gate обеспечивает координацию одного процесса; несколько writers/instances остаются отдельной приёмкой. + +Contracts/backend build, TypeScript и scoped ESLint прошли. test:backups прошёл после разрешённого запуска локальных HTTP-фикстур: первоначальный sandbox EACCES127.0.0.1 не был ошибкой приложения. E2E:37 исходных сценариев file/data/operations/storage и2 новых record-only receipt; после поздней защиты повторены20 затронутых сценариев. Итого39 уникальных, повторные запуски не прибавлены. Production owned SQL/API:5 статусов, download200/404, restore400/not-applied/nativeRequests0, сохранение каталога, recreated-file409 и record-only204/header. Собственные DB/role/container удалены; S3/CHR из этой SQL-фикстуры не опрашивались. + +Production backend/frontend ux-backups237 собраны и развёрнуты:508 backend source/1068 compiled и529 frontend source совпали с локальными манифестами. Реальный Portal login и UI1440/390 показали10 существующих local/missing записей, disabled download/restore, корректное описание удаления (только открытие и отмена), pageerrors0/backupwrites0/globaloverflow0. Исходные10 SQL-строк сравнивались целиком до/после; ни одна не удалена. Отсутствующие старые файлы не восстановлены и не объявлены доступными. Приватный SQL-снимок удалён после сверки. Откат: backend ux-backup234/frontend ux-routing218 сохранены. + +Доказательства: [SQL/API](ux-ui/acceptance/backup-availability237.json), [live UI](ux-ui/acceptance/backups237-live-ui.json), [production сверка](ux-ui/acceptance/backups237-deployment.json). Снимки: [ПК](ux-ui/screens/backups237-missing-desktop.png), [телефон](ux-ui/screens/backups237-missing-mobile.png). + +| Референс | Схема доработки | +| --- | --- | +| [ReUI Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | История → статус файла → доступные действия → подтверждённый результат; missing → только запись → проверка409/receipt204 | + +Следующий238 фиксирует уже выполненное длительное REST-наблюдение. Затем — S3 restore через реальный UI в пределах собственного bridge и retention. Полная конфигурация CHR, arbitrary PKI, другие ОС, public IPv6, multi-instance и внешние jobs не приняты этим блоком. + ## 12.5.236 — коррелированная диагностика REST и гипотеза RouterOS-сессий (2026-10-14) По поручению пользователя остаток окна направлен на REST. Исторические220/224401 получены непосредственно от104.165.244.7, включая независимый HTTP Connection close; UI/кэш приложения не являются единственным источником. У старого221500 на /interface/gre не сохранён detail/body, поэтому назначить ему причину по HTTP code нельзя. diff --git a/docs/ux-ui-remaining-acceptance.md b/docs/ux-ui-remaining-acceptance.md index 13a026f0..e0ac665b 100644 --- a/docs/ux-ui-remaining-acceptance.md +++ b/docs/ux-ui-remaining-acceptance.md @@ -1,10 +1,28 @@ # Оставшаяся внешняя приёмка — 2026-10-14 -Актуальное продолжение 2026-10-14:236 — по приоритету пользователя проверен REST:384 paired samples из workstation/Debian +40 diagnostics, отказов0; current credentials/policies/addresses/service load исправны. На7.24.5 сохранены828/304 rest-api sessions и2 api на каждом; после3 дополнительных reads delta0. Найден vendor fix inactive REST sessions в7.25rc, это гипотеза, причинность401/500 не доказана. Runtime ux-backup234/ux-routing218 unchanged. Следующий REST шаг — корреляция сбоя/renewal или согласованный firmware control; общий следующий237 — missing local files UX. Этап12.5 открыт. +Актуальное продолжение 2026-10-14:237 — история бэкапов показывает подтверждённое отсутствие локального файла; скачивание/restore заблокированы, удаление только записи требует отдельного режима и receipt. Production SQL/API, 39 уникальных E2E и live UI ПК/телефон приняты в записанных границах. Backend/frontend ux-backups237; исходные10 строк каталога сохранены без изменений. Следующий238 — длительное REST-наблюдение; затем S3 restore через UI и retention. Этап12.5 открыт. -Это актуальная матрица после 12.5.236. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. +Это актуальная матрица после 12.5.237. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. -Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-backup234`, frontend `ux-routing218`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. +Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-backups237`, frontend `ux-backups237`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. + +## 12.5.237 — отсутствующие файлы бэкапов и безопасное удаление записи (2026-10-14) + +API добавляет необязательный localFileStatus: available/missing/unknown/not-stored. Локальные проверки ограничены восемью workers и не опрашивают S3. Ошибка доступа/каталог/некорректный путь не превращаются в «файл отсутствует». Для local/missing существующая таблица ReUI показывает «Нет файла», описание и блокирует скачивание/restore. Для both/missing остаётся запрос S3; состояние unknown и старый DTO не блокируются по догадке. Проверка наличия файла перед импортом остаётся обязательной на backend. + +Подтверждение удаления отсутствующего локального файла обещает только удаление строки истории. UI отправляет recordOnly=true и требует HTTP204 с X-Backup-Delete-Mode:record-only. Backend повторно проверяет missing/local/no-S3; появившийся файл даёт409 с сохранением файла и каталога. В этом режиме unlink никогда не вызывается, включая внешнее появление файла после проверки. Общий gate обеспечивает координацию одного процесса; несколько writers/instances остаются отдельной приёмкой. + +Contracts/backend build, TypeScript и scoped ESLint прошли. test:backups прошёл после разрешённого запуска локальных HTTP-фикстур: первоначальный sandbox EACCES127.0.0.1 не был ошибкой приложения. E2E:37 исходных сценариев file/data/operations/storage и2 новых record-only receipt; после поздней защиты повторены20 затронутых сценариев. Итого39 уникальных, повторные запуски не прибавлены. Production owned SQL/API:5 статусов, download200/404, restore400/not-applied/nativeRequests0, сохранение каталога, recreated-file409 и record-only204/header. Собственные DB/role/container удалены; S3/CHR из этой SQL-фикстуры не опрашивались. + +Production backend/frontend ux-backups237 собраны и развёрнуты:508 backend source/1068 compiled и529 frontend source совпали с локальными манифестами. Реальный Portal login и UI1440/390 показали10 существующих local/missing записей, disabled download/restore, корректное описание удаления (только открытие и отмена), pageerrors0/backupwrites0/globaloverflow0. Исходные10 SQL-строк сравнивались целиком до/после; ни одна не удалена. Отсутствующие старые файлы не восстановлены и не объявлены доступными. Приватный SQL-снимок удалён после сверки. Откат: backend ux-backup234/frontend ux-routing218 сохранены. + +Доказательства: [SQL/API](ux-ui/acceptance/backup-availability237.json), [live UI](ux-ui/acceptance/backups237-live-ui.json), [production сверка](ux-ui/acceptance/backups237-deployment.json). Снимки: [ПК](ux-ui/screens/backups237-missing-desktop.png), [телефон](ux-ui/screens/backups237-missing-mobile.png). + +| Референс | Схема доработки | +| --- | --- | +| [ReUI Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | История → статус файла → доступные действия → подтверждённый результат; missing → только запись → проверка409/receipt204 | + +Следующий238 фиксирует уже выполненное длительное REST-наблюдение. Затем — S3 restore через реальный UI в пределах собственного bridge и retention. Полная конфигурация CHR, arbitrary PKI, другие ОС, public IPv6, multi-instance и внешние jobs не приняты этим блоком. ## Остаток приёмки по группам — 2026-10-14 @@ -15,7 +33,7 @@ | REST и достоверность данных | Ошибки OSPF/BFD/BGP/GRE/WAN215–221 явные; latest API smoke222 прошёл | Correlated236:384 samples/40 diagnostics без отказов; current policies/address/load исправны.828/304 retained REST sessions и vendor inactive-session fix — гипотеза; причинность401/500,10min renewal/version control остаются открытыми | | Клиентские ОС и внешняя сеть | CHR↔CHR/Linux, private IPv4/ULA и synthetic IPv6/IPFIX; Android full-tunnel/reconnect227 подтверждены пользователем | Windows/macOS/iOS VPN, расширенная Android приёмка, независимые WAN/multi-WAN и public IPv6; public IPv6 на текущем стенде отсутствует | | Масштаб и длительная устойчивость | Контролируемые lock/dedupe/loss/recovery и короткие native проверки | Несколько backend/worker/writers, длительный rekey/REST/traffic, physical disconnect и maximum throughput | -| Внешние хранилища и jobs | Локальные API/errors/RBAC/owned SQL; реальные Portal1.3.1/Telegram195–201 и Selectel S3/client/API/ownSQL228–229 | Large native exports проверены HTTP fixture; S3 restore через UI, missing local files UX, миграция между buckets/multi-instance и retention; provider/container/Gitea/MaxMind jobs с соответствующими endpoints/аккаунтами; private/paid credentials не выдумывать | +| Внешние хранилища и jobs | Локальные API/errors/RBAC/owned SQL; реальные Portal1.3.1/Telegram195–201 и Selectel S3/client/API/ownSQL228–229 | Large native exports проверены HTTP fixture; Missing local files UX237 принят; S3 restore через UI, миграция между buckets/multi-instance и retention; provider/container/Gitea/MaxMind jobs с соответствующими endpoints/аккаунтами; private/paid credentials не выдумывать | | Расширенные сетевые конфигурации | GRE/WG/VXLAN/IPsec/OSPF/BFD/BGP сценарии и точные границы записаны | from-pool/EUI-64/multiple-address import/export, outer IPv6, произвольная PKI/OID/policy/path-length и длительные переходы; BFD-only gate гарантией не стал | | Общий UX, доступность и производительность | Полная UI192 исторически; latest backend25/UI52, scoped contrast/live218 и keyboard206 | Свежая UI226 принята в записанных границах; профильный performance и ручная accessibility/screen-reader приёмка; scoped axe не полный WCAG | | Границы выпуска | План/доказательства/датированные блоки сохранены; коммиты локальные | Определить требуемые конфигурации и принять результат в этих границах; невыполненные варианты остаются явными ограничениями | @@ -42,7 +60,7 @@ Portal offline JWT действует доexp после logout/role change; э ## Следующая последовательность — 2026-10-14 -1. **215 — legacy OSPF/BFD read errors:** пять GET endpoints скрывают native failures; сохранить success envelopes, различить empty/partial/all-failed/disabled contexts в own SQL/HTTP fixtures. +1. **238 — REST длительное окно:** сохранить протокол наблюдения более10 минут и изменения API session records без недоказанного объяснения прежних401/500. Затем — S3 restore через UI и retention только собственных объектов. 2. **Профильный контроль:** для новых изменений выполнять затронутые SQL/API/UI проверки. Общая регрессия192 выполнена; не повторять её без изменений общей основы или нового основания. 3. **Внешние зависимости:** Portal issuer/login/revoke, Telegram bot/chat/разрешённое сообщение, клиентские ОС, независимые WAN/public IPv6 и scale/multi-instance — отдельные группы по таблице выше. Ограничения текущего стенда не превращать в обещание приёмки всех конфигураций. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index d8a7f2e4..61b843ce 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -1,6 +1,6 @@ # Этап 12: итоговая приёмка UX/UI -Актуальное продолжение 2026-10-14:236 — по приоритету пользователя проверен REST:384 paired samples из workstation/Debian +40 diagnostics, отказов0; current credentials/policies/addresses/service load исправны. На7.24.5 сохранены828/304 rest-api sessions и2 api на каждом; после3 дополнительных reads delta0. Найден vendor fix inactive REST sessions в7.25rc, это гипотеза, причинность401/500 не доказана. Runtime ux-backup234/ux-routing218 unchanged. Следующий REST шаг — корреляция сбоя/renewal или согласованный firmware control; общий следующий237 — missing local files UX. Этап12.5 открыт. +Актуальное продолжение 2026-10-14:237 — история бэкапов показывает подтверждённое отсутствие локального файла; скачивание/restore заблокированы, удаление только записи требует отдельного режима и receipt. Production SQL/API, 39 уникальных E2E и live UI ПК/телефон приняты в записанных границах. Backend/frontend ux-backups237; исходные10 строк каталога сохранены без изменений. Следующий238 — длительное REST-наблюдение; затем S3 restore через UI и retention. Этап12.5 открыт. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. @@ -4152,6 +4152,24 @@ Real Portal/browser API, provider local verified: создан один job ро Runtime backend ux-backup234/frontend ux-routing218 healthy; новое application image для volume-only fix не требовалось. Пароли CHR не менялись, S3 mutations0 в235, configs body/JWT не опубликованы, private local manifest удалён. Исходные10 missing local files не восстановлены и records не удалены. Force-recreate — не host reboot/power loss/fsync/DR приёмка. Далее236 — UX недоступных local files, затем version retention границы и оставшиеся7 группы матрицы; multi-instance guard не принимается. Этап12.5 открыт. +## 12.5.237 — отсутствующие файлы бэкапов и безопасное удаление записи (2026-10-14) + +API добавляет необязательный localFileStatus: available/missing/unknown/not-stored. Локальные проверки ограничены восемью workers и не опрашивают S3. Ошибка доступа/каталог/некорректный путь не превращаются в «файл отсутствует». Для local/missing существующая таблица ReUI показывает «Нет файла», описание и блокирует скачивание/restore. Для both/missing остаётся запрос S3; состояние unknown и старый DTO не блокируются по догадке. Проверка наличия файла перед импортом остаётся обязательной на backend. + +Подтверждение удаления отсутствующего локального файла обещает только удаление строки истории. UI отправляет recordOnly=true и требует HTTP204 с X-Backup-Delete-Mode:record-only. Backend повторно проверяет missing/local/no-S3; появившийся файл даёт409 с сохранением файла и каталога. В этом режиме unlink никогда не вызывается, включая внешнее появление файла после проверки. Общий gate обеспечивает координацию одного процесса; несколько writers/instances остаются отдельной приёмкой. + +Contracts/backend build, TypeScript и scoped ESLint прошли. test:backups прошёл после разрешённого запуска локальных HTTP-фикстур: первоначальный sandbox EACCES127.0.0.1 не был ошибкой приложения. E2E:37 исходных сценариев file/data/operations/storage и2 новых record-only receipt; после поздней защиты повторены20 затронутых сценариев. Итого39 уникальных, повторные запуски не прибавлены. Production owned SQL/API:5 статусов, download200/404, restore400/not-applied/nativeRequests0, сохранение каталога, recreated-file409 и record-only204/header. Собственные DB/role/container удалены; S3/CHR из этой SQL-фикстуры не опрашивались. + +Production backend/frontend ux-backups237 собраны и развёрнуты:508 backend source/1068 compiled и529 frontend source совпали с локальными манифестами. Реальный Portal login и UI1440/390 показали10 существующих local/missing записей, disabled download/restore, корректное описание удаления (только открытие и отмена), pageerrors0/backupwrites0/globaloverflow0. Исходные10 SQL-строк сравнивались целиком до/после; ни одна не удалена. Отсутствующие старые файлы не восстановлены и не объявлены доступными. Приватный SQL-снимок удалён после сверки. Откат: backend ux-backup234/frontend ux-routing218 сохранены. + +Доказательства: [SQL/API](ux-ui/acceptance/backup-availability237.json), [live UI](ux-ui/acceptance/backups237-live-ui.json), [production сверка](ux-ui/acceptance/backups237-deployment.json). Снимки: [ПК](ux-ui/screens/backups237-missing-desktop.png), [телефон](ux-ui/screens/backups237-missing-mobile.png). + +| Референс | Схема доработки | +| --- | --- | +| [ReUI Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | История → статус файла → доступные действия → подтверждённый результат; missing → только запись → проверка409/receipt204 | + +Следующий238 фиксирует уже выполненное длительное REST-наблюдение. Затем — S3 restore через реальный UI в пределах собственного bridge и retention. Полная конфигурация CHR, arbitrary PKI, другие ОС, public IPv6, multi-instance и внешние jobs не приняты этим блоком. + ## 12.5.236 — коррелированная диагностика REST и гипотеза RouterOS-сессий (2026-10-14) По поручению пользователя остаток окна направлен на REST. Исторические220/224401 получены непосредственно от104.165.244.7, включая независимый HTTP Connection close; UI/кэш приложения не являются единственным источником. У старого221500 на /interface/gre не сохранён detail/body, поэтому назначить ему причину по HTTP code нельзя. diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index 830f0ed3..aeaae4b5 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -1,6 +1,6 @@ # Тестовый сервер UX/UI — 2026-10-10 -Актуальное продолжение 2026-10-14:236 — по приоритету пользователя проверен REST:384 paired samples из workstation/Debian +40 diagnostics, отказов0; current credentials/policies/addresses/service load исправны. На7.24.5 сохранены828/304 rest-api sessions и2 api на каждом; после3 дополнительных reads delta0. Найден vendor fix inactive REST sessions в7.25rc, это гипотеза, причинность401/500 не доказана. Runtime ux-backup234/ux-routing218 unchanged. Следующий REST шаг — корреляция сбоя/renewal или согласованный firmware control; общий следующий237 — missing local files UX. Этап12.5 открыт. +Актуальное продолжение 2026-10-14:237 — история бэкапов показывает подтверждённое отсутствие локального файла; скачивание/restore заблокированы, удаление только записи требует отдельного режима и receipt. Production SQL/API, 39 уникальных E2E и live UI ПК/телефон приняты в записанных границах. Backend/frontend ux-backups237; исходные10 строк каталога сохранены без изменений. Следующий238 — длительное REST-наблюдение; затем S3 restore через UI и retention. Этап12.5 открыт. | Параметр | Проверенное состояние | | --- | --- | @@ -34,6 +34,24 @@ OSPF84: real GRE Full adjacency/private routes/Ping3/3 и4 live PC/320px URL/bro BFD85: Up/Full/own route/Ping,4 live UI и loss/withdrawal/recovery приняты; exact own cleanup8 коллекций×2 passed. Withdrawal5,245ms не подтверждает sub-second BFD timing; этот вопрос открыт. Runtime routing83 без новой сборки. [Отчёт](ux-ui/acceptance/ospf-bfd-loss-2026-10-09.json). +## 12.5.237 — отсутствующие файлы бэкапов и безопасное удаление записи (2026-10-14) + +API добавляет необязательный localFileStatus: available/missing/unknown/not-stored. Локальные проверки ограничены восемью workers и не опрашивают S3. Ошибка доступа/каталог/некорректный путь не превращаются в «файл отсутствует». Для local/missing существующая таблица ReUI показывает «Нет файла», описание и блокирует скачивание/restore. Для both/missing остаётся запрос S3; состояние unknown и старый DTO не блокируются по догадке. Проверка наличия файла перед импортом остаётся обязательной на backend. + +Подтверждение удаления отсутствующего локального файла обещает только удаление строки истории. UI отправляет recordOnly=true и требует HTTP204 с X-Backup-Delete-Mode:record-only. Backend повторно проверяет missing/local/no-S3; появившийся файл даёт409 с сохранением файла и каталога. В этом режиме unlink никогда не вызывается, включая внешнее появление файла после проверки. Общий gate обеспечивает координацию одного процесса; несколько writers/instances остаются отдельной приёмкой. + +Contracts/backend build, TypeScript и scoped ESLint прошли. test:backups прошёл после разрешённого запуска локальных HTTP-фикстур: первоначальный sandbox EACCES127.0.0.1 не был ошибкой приложения. E2E:37 исходных сценариев file/data/operations/storage и2 новых record-only receipt; после поздней защиты повторены20 затронутых сценариев. Итого39 уникальных, повторные запуски не прибавлены. Production owned SQL/API:5 статусов, download200/404, restore400/not-applied/nativeRequests0, сохранение каталога, recreated-file409 и record-only204/header. Собственные DB/role/container удалены; S3/CHR из этой SQL-фикстуры не опрашивались. + +Production backend/frontend ux-backups237 собраны и развёрнуты:508 backend source/1068 compiled и529 frontend source совпали с локальными манифестами. Реальный Portal login и UI1440/390 показали10 существующих local/missing записей, disabled download/restore, корректное описание удаления (только открытие и отмена), pageerrors0/backupwrites0/globaloverflow0. Исходные10 SQL-строк сравнивались целиком до/после; ни одна не удалена. Отсутствующие старые файлы не восстановлены и не объявлены доступными. Приватный SQL-снимок удалён после сверки. Откат: backend ux-backup234/frontend ux-routing218 сохранены. + +Доказательства: [SQL/API](ux-ui/acceptance/backup-availability237.json), [live UI](ux-ui/acceptance/backups237-live-ui.json), [production сверка](ux-ui/acceptance/backups237-deployment.json). Снимки: [ПК](ux-ui/screens/backups237-missing-desktop.png), [телефон](ux-ui/screens/backups237-missing-mobile.png). + +| Референс | Схема доработки | +| --- | --- | +| [ReUI Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | История → статус файла → доступные действия → подтверждённый результат; missing → только запись → проверка409/receipt204 | + +Следующий238 фиксирует уже выполненное длительное REST-наблюдение. Затем — S3 restore через реальный UI в пределах собственного bridge и retention. Полная конфигурация CHR, arbitrary PKI, другие ОС, public IPv6, multi-instance и внешние jobs не приняты этим блоком. + ## Развёртывание приложения Чистые frontend/backend build contexts подготовлены по .gitea/workflows/docker.yml из закреплённого Git HEAD;образы собраны на сервере без push. Локально docker CLI сейчас отсутствует, на сервере доступен Docker. В корневом .dockerignore исключён backend, поэтому обычный backend build из корня не повторяет CI: использовать отдельный backend context, как main workflow. Не передавать .env.local,backend/.env,ключи и локальные node_modules/.next. diff --git a/docs/ux-ui/acceptance/backup-availability237.json b/docs/ux-ui/acceptance/backup-availability237.json new file mode 100644 index 00000000..104bd0c7 --- /dev/null +++ b/docs/ux-ui/acceptance/backup-availability237.json @@ -0,0 +1,40 @@ +{ + "date": "2026-10-14", + "block": "12.5.237", + "passed": true, + "checks": [ + { + "listStates": { + "directory237": "unknown", + "remote237": "not-stored", + "both237": "missing", + "missing237": "missing", + "available237": "available" + } + }, + { + "availableDownload": 200 + }, + { + "missingDownload": 404 + }, + { + "missingRestore": 400, + "outcome": "not-applied", + "nativeRequests": 0 + }, + { + "failurePreservesCatalog": true + }, + { + "recreatedFilePreserved": 409 + }, + { + "missingRecordDeletion": 204, + "mode": "record-only" + } + ], + "externalS3Contacts": 0, + "chrContacts": 0, + "ownedDatabaseAndRoleRemoved": true +} diff --git a/docs/ux-ui/acceptance/backups237-deployment.json b/docs/ux-ui/acceptance/backups237-deployment.json new file mode 100644 index 00000000..b463cb32 --- /dev/null +++ b/docs/ux-ui/acceptance/backups237-deployment.json @@ -0,0 +1,35 @@ +{ + "date": "2026-10-14", + "block": "12.5.237", + "backendSourceMatched": 508, + "backendCompiledMatched": 1068, + "frontendSourceMatched": 529, + "originalCatalogAllFieldsMatched": true, + "originalCatalogRows": 10, + "privateCatalogSnapshotRemoved": true, + "containers": { + "backend": { + "tag": "mmapp-test/backend:ux-backups237", + "imageId": "sha256:184aa8844b1c3aaa12e06ee47332a214e84e6962405360acb4026b392dc407ed", + "state": "running", + "health": "healthy", + "backupsMounts": [ + { + "source": "/opt/mmapp-test/data/backups", + "destination": "/app/storage/backups" + } + ] + }, + "frontend": { + "tag": "mmapp-test/frontend:ux-backups237", + "imageId": "sha256:baf9989cde6527715890e608c2c3271c5ea40dfb235ee47a48e21939b0ec1fd0", + "state": "running", + "health": null, + "backupsMounts": [] + } + }, + "rollbackTags": { + "backend": "ux-backup234", + "frontend": "ux-routing218" + } +} diff --git a/docs/ux-ui/acceptance/backups237-live-ui.json b/docs/ux-ui/acceptance/backups237-live-ui.json new file mode 100644 index 00000000..0ccdee4b --- /dev/null +++ b/docs/ux-ui/acceptance/backups237-live-ui.json @@ -0,0 +1,24 @@ +{ + "date": "2026-10-14", + "block": "12.5.237", + "realPortalLogin": true, + "noMocks": true, + "existingRows": 10, + "allLocalFilesMissing": true, + "downloadAndRestoreDisabled": true, + "recordOnlyDialogCancelled": true, + "pageErrors": [], + "backupWrites": 0, + "screens": [ + { + "width": 1440, + "globalOverflow": false, + "screenshot": "docs/ux-ui/screens/backups237-missing-desktop.png" + }, + { + "width": 390, + "globalOverflow": false, + "screenshot": "docs/ux-ui/screens/backups237-missing-mobile.png" + } + ] +} diff --git a/docs/ux-ui/screens/backups237-missing-desktop.png b/docs/ux-ui/screens/backups237-missing-desktop.png new file mode 100644 index 00000000..4058f631 Binary files /dev/null and b/docs/ux-ui/screens/backups237-missing-desktop.png differ diff --git a/docs/ux-ui/screens/backups237-missing-mobile.png b/docs/ux-ui/screens/backups237-missing-mobile.png new file mode 100644 index 00000000..ddd2bb63 Binary files /dev/null and b/docs/ux-ui/screens/backups237-missing-mobile.png differ diff --git a/lib/backup-file-state.ts b/lib/backup-file-state.ts new file mode 100644 index 00000000..aed3ee9e --- /dev/null +++ b/lib/backup-file-state.ts @@ -0,0 +1,13 @@ +import type { Backup } from "@/lib/data" + +export function backupFileMissing(backup: Pick): boolean { + return backup.storage === "local" && backup.localFileStatus === "missing" +} + +export function backupLocalFileDescription(backup: Pick): string | null { + if (backup.localFileStatus === "missing") return backup.storage === "both" + ? "Локальная копия отсутствует. Файл будет запрошен из S3." + : "Локальный файл отсутствует." + if (backup.localFileStatus === "unknown") return "Не удалось проверить локальный файл." + return null +} diff --git a/lib/data.ts b/lib/data.ts index 6349faff..384b5bda 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -198,6 +198,7 @@ export interface Backup { notes: string storage: "local" | "s3" | "both" uploadError?: string + localFileStatus?: "available" | "missing" | "unknown" | "not-stored" } // ─── VXLAN ─────────────────────────────────────────────────────────────────── diff --git a/packages/contracts/src/backups.ts b/packages/contracts/src/backups.ts index 62641bed..fe7d1be8 100644 --- a/packages/contracts/src/backups.ts +++ b/packages/contracts/src/backups.ts @@ -4,6 +4,8 @@ export const backupFrequencySchema = z.enum(["daily", "weekly", "monthly"]) export const backupFormatSchema = z.enum(["rsc", "backup"]) export const backupStorageProviderSchema = z.enum(["local", "s3"]) export const backupObjectStorageSchema = z.enum(["local", "s3", "both"]) +export const backupLocalFileStatusSchema = z.enum(["available", "missing", "unknown", "not-stored"]) +export type BackupLocalFileStatus = z.infer export const backupScheduleSettingsDtoSchema = z.object({ enabled: z.boolean(), @@ -74,6 +76,7 @@ export const backupItemDtoSchema = z.object({ storage: backupObjectStorageSchema, s3Key: z.string().nullable().optional(), uploadError: z.string().nullable().optional(), + localFileStatus: backupLocalFileStatusSchema.optional(), }) export type BackupStorageSettingsDto = z.infer diff --git a/shared/api/backups.ts b/shared/api/backups.ts index d6476277..aa2f87f7 100644 --- a/shared/api/backups.ts +++ b/shared/api/backups.ts @@ -38,9 +38,10 @@ export async function getBackupJob(baseUrl: string, jobId: string, options?: Req if (result.id !== jobId) throw new Error("Ответ относится к другой задаче резервного копирования") return result } -export async function deleteBackup(baseUrl: string, id: string, options?: RequestInit): Promise { - const response = await requestBlob(baseUrl, "/api/backups/" + encodeURIComponent(id), backupOptions({ ...options, method: "DELETE" })) +export async function deleteBackup(baseUrl: string, id: string, options?: RequestInit, recordOnly = false): Promise { + const response = await requestBlob(baseUrl, "/api/backups/" + encodeURIComponent(id) + (recordOnly ? "?recordOnly=true" : ""), backupOptions({ ...options, method: "DELETE" })) if (response.status !== 204) throw new Error("Удаление резервной копии не подтверждено: ожидается HTTP 204") + if (recordOnly && response.headers.get("X-Backup-Delete-Mode") !== "record-only") throw new Error("Не подтверждено удаление только записи истории. Обновите данные перед повтором.") } export async function restoreBackup(baseUrl: string, id: string, options?: RequestInit, expectedServerId?: number) { const result = restoreBackupResponseSchema.parse(await backupJson(baseUrl, "/api/backups/" + encodeURIComponent(id) + "/restore", { ...options, method: "POST" })) diff --git a/tests/e2e/backups-file-state.spec.ts b/tests/e2e/backups-file-state.spec.ts new file mode 100644 index 00000000..aadb31c2 --- /dev/null +++ b/tests/e2e/backups-file-state.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "@playwright/test" +import { backup, setupBackups } from "./fixtures/backups" + +for (const width of [1440, 390]) test(`Бэкапы: отсутствующий локальный файл ${width}`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); await setupBackups(page) + await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, localFileStatus: "missing" }] })) + let operations = 0; page.on("request", r => { if (/\/backups\/[^/]+\/(restore|download)$/.test(new URL(r.url()).pathname)) operations++ }) + await page.goto("/backups") + await expect(page.getByText("Нет файла", { exact: true })).toBeVisible() + await expect(page.getByText("Локальный файл отсутствует.", { exact: true })).toBeVisible() + await expect(page.getByRole("button", { name: "Скачать " + backup.filename, exact: true })).toBeDisabled() + await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeDisabled() + await page.getByRole("button", { name: "Удалить " + backup.filename, exact: true }).click() + const dialog = page.getByRole("alertdialog"); await expect(dialog).toContainText("Удалить запись о бэкапе?"); await expect(dialog).toContainText("Будет удалена только запись из истории.") + await dialog.getByRole("button", { name: "Отмена", exact: true }).click() + expect(operations).toBe(0); expect(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1)).toBe(false) +}) + +test("Бэкапы: потеря local copy сохраняет скачивание из S3", async ({ page }) => { + await setupBackups(page); await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, storage: "both", localFileStatus: "missing" }] })) + await page.route("**/api/backups/backup-1/download", r => r.fulfill({ contentType: "text/plain; charset=utf-8", body: "# S3 fixture\n" })) + await page.goto("/backups"); await expect(page.getByText("Локальная копия отсутствует. Файл будет запрошен из S3.", { exact: true })).toBeVisible() + await expect(page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true })).toBeEnabled() + const download = page.waitForEvent("download"); await page.getByRole("button", { name: "Скачать " + backup.filename, exact: true }).click(); expect((await download).suggestedFilename()).toBe(backup.filename) +}) + +for (const status of ["unknown", undefined]) test(`Бэкапы: неизвестный или старый receipt ${status}`, async ({ page }) => { + await setupBackups(page); await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, ...(status ? { localFileStatus: status } : {}) }] })) + await page.goto("/backups"); await expect(page.getByText(backup.filename, { exact: true })).toBeVisible() + await expect(page.getByText("Нет файла", { exact: true })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Скачать " + backup.filename, exact: true })).toBeEnabled() + if (status) await expect(page.getByText("Не удалось проверить локальный файл.", { exact: true })).toBeVisible() +}) + +test("Бэкапы: исчезновение после первого чтения подтверждается новым receipt", async ({ page }) => { + await setupBackups(page); let missing = false + await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, localFileStatus: missing ? "missing" : "available" }] })) + await page.goto("/backups"); const restore = page.getByRole("button", { name: "Восстановить " + backup.filename, exact: true }); await expect(restore).toBeEnabled() + missing = true; await page.getByRole("button", { name: "Обновить", exact: true }).click(); await expect(restore).toBeDisabled() + await expect(page.getByText("Нет файла", { exact: true })).toBeVisible() +}) diff --git a/tests/e2e/backups-record-only.spec.ts b/tests/e2e/backups-record-only.spec.ts new file mode 100644 index 00000000..e08825d3 --- /dev/null +++ b/tests/e2e/backups-record-only.spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from "@playwright/test" +import { backup, setupBackups } from "./fixtures/backups" + +for (const confirmed of [true, false]) test(`Бэкапы: удаление отсутствующего файла требует record-only receipt ${confirmed}`, async ({ page }) => { + await setupBackups(page); await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, localFileStatus: "missing" }] })) + let calls = 0 + await page.route("**/api/backups/backup-1?recordOnly=true", r => { calls++; return r.fulfill({ status: 204, headers: confirmed ? { "X-Backup-Delete-Mode": "record-only" } : {} }) }) + 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() + if (confirmed) await expect(dialog).toBeHidden() + else { await expect(dialog).toBeVisible(); await expect(dialog).toContainText("Не подтверждено удаление только записи истории") } + expect(calls).toBe(1) +})