fix(backups): показать отсутствие файлов и защитить удаление записей
This commit is contained in:
1 parent
e452e528e1
commit
6617b776bb
26 files changed
+420
-36
No files matched your search
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
@@ -215,10 +215,17 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }) }
|
||||
@@ -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<T extends BackupItemDto>(
|
||||
rows: T[], directory: string, inspect = lstat,
|
||||
): Promise<T[]> {
|
||||
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
|
||||
}
|
||||
@@ -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<void> {
|
||||
|
||||
export async function listBackups(): Promise<BackupMeta[]> {
|
||||
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<BackupMeta | null> {
|
||||
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<void> {
|
||||
@@ -92,14 +95,23 @@ async function getBackupRow(id: string): Promise<BackupRow | undefined> {
|
||||
return (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
|
||||
}
|
||||
|
||||
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
|
||||
return withBackupStorageOperation(() => deleteStoredBackupRecord(id))
|
||||
export async function deleteBackupRecord(id: string, options: { recordOnly?: boolean } = {}): Promise<BackupMeta | null> {
|
||||
return withBackupStorageOperation(() => deleteStoredBackupRecord(id, Boolean(options.recordOnly)))
|
||||
}
|
||||
|
||||
async function deleteStoredBackupRecord(id: string): Promise<BackupMeta | null> {
|
||||
async function deleteStoredBackupRecord(id: string, recordOnly: boolean): Promise<BackupMeta | null> {
|
||||
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<Buffer> {
|
||||
}
|
||||
|
||||
async function readStoredBackupContent(meta: BackupMeta): Promise<Buffer> {
|
||||
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<Buffer> {
|
||||
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 }> {
|
||||
|
||||
@@ -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({
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<AlertCircleIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Удалить бэкап?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{backup && backupFileMissing(backup) ? "Удалить запись о бэкапе?" : "Удалить бэкап?"}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="[overflow-wrap:anywhere]">
|
||||
Файл <span className="font-mono text-foreground">{backup?.filename}</span> будет удалён
|
||||
{backup?.storage === "s3" || backup?.storage === "both" ? " локально; объект S3 — через текущее подключение к хранилищу" : " с диска"}.
|
||||
{backup && backupFileMissing(backup) ? <>Локальный файл <span className="font-mono text-foreground">{backup.filename}</span> отсутствует. Будет удалена только запись из истории.</> : <>Файл <span className="font-mono text-foreground">{backup?.filename}</span> будет удалён
|
||||
{backup?.storage === "s3" || backup?.storage === "both" ? " локально; объект S3 — через текущее подключение к хранилищу" : " с диска"}.</>}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{error && <p role="alert" className="text-sm text-destructive break-words">{error}</p>}
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<span title={b.filename} className="font-mono text-xs font-medium block break-all">{b.filename}</span>
|
||||
{backupLocalFileDescription(b) && <span id={`backup-file-${encodeURIComponent(b.id)}`} className="text-xs text-muted-foreground block">{backupLocalFileDescription(b)}</span>}
|
||||
{b.uploadError ? (
|
||||
<span className="text-[11px] text-destructive truncate block">{b.uploadError}</span>
|
||||
) : null}
|
||||
@@ -134,7 +136,12 @@ function BackupsDataGrid({
|
||||
accessorKey: "storage",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Хранилище" />,
|
||||
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 variant={badge.variant} size="sm" radius="full">
|
||||
{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)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
@@ -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)}
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
|
||||
@@ -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 и учётные записи. Зафиксировать ожидаемые изменения и способ восстановления стенда.
|
||||
|
||||
+19
-1
@@ -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, внешние аккаунты/клиентские ОС и оставшиеся профильные сетевые ограничения открыты.
|
||||
|
||||
+19
-1
@@ -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 нельзя.
|
||||
|
||||
@@ -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 — отдельные группы по таблице выше. Ограничения текущего стенда не превращать в обещание приёмки всех конфигураций.
|
||||
|
||||
|
||||
+19
-1
@@ -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 нельзя.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,13 @@
|
||||
import type { Backup } from "@/lib/data"
|
||||
|
||||
export function backupFileMissing(backup: Pick<Backup, "storage" | "localFileStatus">): boolean {
|
||||
return backup.storage === "local" && backup.localFileStatus === "missing"
|
||||
}
|
||||
|
||||
export function backupLocalFileDescription(backup: Pick<Backup, "storage" | "localFileStatus">): string | null {
|
||||
if (backup.localFileStatus === "missing") return backup.storage === "both"
|
||||
? "Локальная копия отсутствует. Файл будет запрошен из S3."
|
||||
: "Локальный файл отсутствует."
|
||||
if (backup.localFileStatus === "unknown") return "Не удалось проверить локальный файл."
|
||||
return null
|
||||
}
|
||||
@@ -198,6 +198,7 @@ export interface Backup {
|
||||
notes: string
|
||||
storage: "local" | "s3" | "both"
|
||||
uploadError?: string
|
||||
localFileStatus?: "available" | "missing" | "unknown" | "not-stored"
|
||||
}
|
||||
|
||||
// ─── VXLAN ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<typeof backupLocalFileStatusSchema>
|
||||
|
||||
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<typeof backupStorageSettingsDtoSchema>
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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" }))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user