fix(backups): подтверждать настройки и последнюю проверку S3

This commit is contained in:
Denozordec committed 2026-10-01 15:37:00 +07:00
1 parent 8e01666e0e
commit 30bd479a6f
14 files changed
+208 -64

No files matched your search

+16 -6
View File
@@ -5,7 +5,7 @@ import { PageHeader } from "@/components/page-header"
import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data"
import { createFilterQuery } from "@/components/reui/filters/filters-query"
import type { FilterQuery } from "@/components/reui/filters/filters-types"
import type { BackupScheduleSettingsDto, BackupStorageSettingsDto } from "@mmapp/contracts/backups"
import { backupStorageSettingsDtoSchema, type BackupScheduleSettingsDto, type BackupStorageSettingsDto } from "@mmapp/contracts/backups"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Button } from "@/components/ui/button"
@@ -36,7 +36,7 @@ import {
testBackupStorage,
type BackupItem,
} from "@/shared/api/backups"
import { requestJson, requestBlob } from "@/shared/api/http-client"
import { ApiClientError, requestJson, requestBlob } from "@/shared/api/http-client"
import { toast } from "sonner"
import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
import { BackupsHistory } from "@/components/backups/backups-history"
@@ -95,6 +95,7 @@ function BackupsContent() {
const [syncBusy, setSyncBusy] = useState(false)
const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule)
const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm)
const [storageIsDirty, setStorageIsDirty] = useState(false)
const [savedSchedule, setSavedSchedule] = useState<BackupScheduleSettingsDto | null>(null)
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
@@ -108,7 +109,7 @@ function BackupsContent() {
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) }
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => { storageDirty.current = true; setStorage(s => ({ ...s, [k]: v })) }
const setStore = <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => { if (k !== "showPassword") { storageDirty.current = true; setStorageIsDirty(true) } setStorage(s => ({ ...s, [k]: v })) }
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
const kb = Math.round(b.sizeBytes / 1024)
@@ -199,9 +200,9 @@ function BackupsContent() {
if (!canWrite || !current("storage")) return
const abort = startSettingsMutation(); if (!abort) return
try {
const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage), { signal: abort.signal })
const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage, savedStorage?.secretConfigured), { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; settingsConfirmed("storage")
setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; setStorageIsDirty(false); settingsConfirmed("storage")
toast.success("Хранилище сохранено")
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { finishSettingsMutation(abort) }
@@ -218,7 +219,15 @@ function BackupsContent() {
setSavedStorage(result); settingsConfirmed("storage")
if (result.lastTestError || !result.lastTestAt || result.provider !== "s3") throw new Error(result.lastTestError || "Проверка S3 не подтверждена")
toast.success("Доступ к S3 подтверждён")
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
} catch (error) {
if (alive.current && !abort.signal.aborted) {
if (error instanceof ApiClientError && error.payload && typeof error.payload === "object" && "settings" in error.payload) {
const parsed = backupStorageSettingsDtoSchema.safeParse(error.payload.settings)
if (parsed.success) setSavedStorage(parsed.data)
}
setOpError(userApiError(error))
}
}
finally { finishSettingsMutation(abort) }
}
async function handleSyncStorage() {
@@ -522,6 +531,7 @@ function BackupsContent() {
onSaveStorage={handleSaveStorage}
scheduleKnown={known("schedule")}
storageKnown={known("storage")}
storageDirty={storageIsDirty}
scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers")}
storageWritable={canWrite && current("storage")}
allEnabled={allEnabled}
+1 -1
View File
@@ -24,7 +24,7 @@
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts",
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
"test:backups": "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-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:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.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",
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts",
+14
View File
@@ -0,0 +1,14 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { mapBackupEntry } from "./backup-dto.js"
const row = { id: "backup", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01 07:00:00.123+07", kind: "manual" as const, notes: null, storage: "local" as const, s3Key: null, s3Etag: null, uploadError: null }
test("PostgreSQL timestamp format is normalized to ISO with the same instant", () => {
const result = mapBackupEntry(row); assert.equal(result.createdAt, "2026-10-01T00:00:00.123Z"); assert.equal(result.sizeBytes, 0); assert.equal(result.serverId, 1)
})
test("removed server and partial S3 failure remain explicit", () => {
const result = mapBackupEntry({ ...row, serverId: null, uploadError: "S3 unavailable" })
assert.equal(result.serverId, null); assert.equal(result.storage, "local"); assert.equal(result.uploadError, "S3 unavailable")
})
test("invalid timestamps and sizes do not turn into truthful backup metadata", () => {
assert.throws(() => mapBackupEntry({ ...row, createdAt: "bad" })); assert.throws(() => mapBackupEntry({ ...row, sizeBytes: -1 }))
})
+9
View File
@@ -0,0 +1,9 @@
import { backupItemDtoSchema } from "@mmapp/contracts/backups"
import type { backupEntries } from "../db/schema.js"
export function mapBackupEntry(row: typeof backupEntries.$inferSelect) {
return backupItemDtoSchema.parse({
id: row.id, serverId: row.serverId, serverName: row.serverName, filename: row.filename,
sizeBytes: row.sizeBytes, createdAt: new Date(row.createdAt).toISOString(), kind: row.kind,
notes: row.notes ?? undefined, storage: row.storage, s3Key: row.s3Key, uploadError: row.uploadError,
})
}
+17 -42
View File
@@ -1,12 +1,14 @@
import { randomUUID } from "node:crypto"
import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises"
import path from "node:path"
import { desc, eq } from "drizzle-orm"
import { and, desc, eq } from "drizzle-orm"
import type {
BackupScheduleSettingsDto,
BackupStorageSettingsDto,
PutBackupStorageSettings,
} from "@mmapp/contracts/backups"
import { mapBackupEntry } from "./backup-dto.js"
import { planBackupStorageUpdate } from "./backup-storage-update.js"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js"
@@ -45,21 +47,7 @@ export type BackupMeta = {
type BackupRow = typeof backupEntries.$inferSelect
function rowToMeta(row: BackupRow): BackupMeta {
return {
id: row.id,
serverId: row.serverId,
serverName: row.serverName,
filename: row.filename,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt,
kind: row.kind,
notes: row.notes ?? undefined,
storage: row.storage ?? "local",
s3Key: row.s3Key,
uploadError: row.uploadError,
}
}
function rowToMeta(row: BackupRow): BackupMeta { return mapBackupEntry(row) }
export async function ensureBackupStorage(): Promise<void> {
await mkdir(BACKUPS_DIR, { recursive: true })
@@ -260,19 +248,7 @@ export async function updateBackupStorageSettings(
): Promise<BackupStorageSettingsDto> {
const prev = await getBackupStorageSettingsRow()
const now = new Date().toISOString()
const secret = patch.s3SecretAccessKey
const next = {
provider: patch.provider ?? prev.provider,
s3Endpoint: patch.s3Endpoint ?? prev.s3Endpoint,
s3Region: patch.s3Region ?? prev.s3Region,
s3Bucket: patch.s3Bucket ?? prev.s3Bucket,
s3Prefix: patch.s3Prefix ?? prev.s3Prefix,
s3AccessKeyId: patch.s3AccessKeyId ?? prev.s3AccessKeyId,
s3SecretAccessKey: secret && secret.length > 0 ? secret : prev.s3SecretAccessKey,
s3ForcePathStyle: patch.s3ForcePathStyle ?? prev.s3ForcePathStyle,
keepLocalCopy: patch.keepLocalCopy ?? prev.keepLocalCopy,
updatedAt: now,
}
const next = planBackupStorageUpdate(prev, patch, now)
if ((await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]) {
await db.update(backupStorageSettings).set(next).where(eq(backupStorageSettings.id, SETTINGS_ID))
} else {
@@ -307,26 +283,25 @@ export async function testBackupStorageConnection(): Promise<BackupStorageSettin
const error = row.provider === "s3"
? "Заполните bucket, ключ доступа и секрет"
: "S3 не выбран"
await persistStorageTest(now, error)
await persistStorageTest(now, error, row.updatedAt)
throw new Error(error)
}
try {
const client = createS3ClientFromConfig(cfg)
await s3TestConnection(client, cfg.bucket)
await persistStorageTest(now, null)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
await persistStorageTest(now, message)
throw new Error(message)
}
return await getBackupStorageSettings()
let error: string | null = null
try { await s3TestConnection(createS3ClientFromConfig(cfg), cfg.bucket) }
catch (reason) { error = reason instanceof Error ? reason.message : String(reason) }
await persistStorageTest(now, error, row.updatedAt)
if (error) throw new Error(error)
const confirmed = await getBackupStorageSettings()
if (!confirmed.updatedAt || Date.parse(confirmed.updatedAt) !== Date.parse(now)) throw new Error("Настройки изменились во время проверки S3; повторите проверку")
return confirmed
}
async function persistStorageTest(at: string, error: string | null) {
async function persistStorageTest(at: string, error: string | null, expectedUpdatedAt: string) {
const exists = (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
const patch = { lastTestAt: at, lastTestError: error, updatedAt: at }
if (exists) {
await db.update(backupStorageSettings).set(patch).where(eq(backupStorageSettings.id, SETTINGS_ID))
const rows = await db.update(backupStorageSettings).set(patch).where(and(eq(backupStorageSettings.id, SETTINGS_ID), eq(backupStorageSettings.updatedAt, expectedUpdatedAt))).returning({ id: backupStorageSettings.id })
if (!rows.length) throw new Error("Настройки изменились во время проверки S3; повторите проверку")
} else {
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...patch })
}
@@ -0,0 +1,20 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { planBackupStorageUpdate } from "./backup-storage-update.js"
const row = { provider: "s3" as const, s3Endpoint: "https://s3.example.test", s3Region: "us-east-1", s3Bucket: "bucket", s3Prefix: "prefix", s3AccessKeyId: "test-key", s3SecretAccessKey: "test-secret", s3ForcePathStyle: false, keepLocalCopy: true, lastTestAt: "2026-10-01T00:00:00Z", lastTestError: null, updatedAt: "2026-10-01T00:00:00Z" }
const now = "2026-10-01T00:01:00Z"
test("unchanged credentials and blank key preserve secret and last receipt", () => {
const result = planBackupStorageUpdate(row, { s3SecretAccessKey: "", s3Bucket: row.s3Bucket }, now)
assert.equal(result.s3SecretAccessKey, row.s3SecretAccessKey); assert.equal(result.lastTestAt, row.lastTestAt)
assert.equal(result.updatedAt, now)
})
test("every effective S3 configuration change invalidates previous test metadata", () => {
for (const patch of [{ provider: "local" as const }, { s3Endpoint: "https://other.example.test" }, { s3Region: "new" }, { s3Bucket: "new" }, { s3Prefix: "new" }, { s3AccessKeyId: "new" }, { s3SecretAccessKey: "new-test-secret" }, { s3ForcePathStyle: true }]) {
const result = planBackupStorageUpdate({ ...row, lastTestError: "old failure" }, patch, now)
assert.equal(result.lastTestAt, null); assert.equal(result.lastTestError, null)
}
})
test("local retention does not invent a connection change or discard a failed test", () => {
const result = planBackupStorageUpdate({ ...row, lastTestError: "failed" }, { keepLocalCopy: false }, now)
assert.equal(result.keepLocalCopy, false); assert.equal(result.lastTestAt, row.lastTestAt); assert.equal(result.lastTestError, "failed")
})
@@ -0,0 +1,15 @@
import type { BackupStorageSettingsDto, PutBackupStorageSettings } from "@mmapp/contracts/backups"
type StorageRecord = Omit<BackupStorageSettingsDto, "secretConfigured"> & { s3SecretAccessKey: string }
/** A test of previous credentials cannot confirm the new configuration. */
export function planBackupStorageUpdate(prev: StorageRecord, patch: PutBackupStorageSettings, updatedAt: string) {
const next = {
provider: patch.provider ?? prev.provider, s3Endpoint: patch.s3Endpoint ?? prev.s3Endpoint, s3Region: patch.s3Region ?? prev.s3Region,
s3Bucket: patch.s3Bucket ?? prev.s3Bucket, s3Prefix: patch.s3Prefix ?? prev.s3Prefix, s3AccessKeyId: patch.s3AccessKeyId ?? prev.s3AccessKeyId,
s3SecretAccessKey: patch.s3SecretAccessKey && patch.s3SecretAccessKey.length > 0 ? patch.s3SecretAccessKey : prev.s3SecretAccessKey,
s3ForcePathStyle: patch.s3ForcePathStyle ?? prev.s3ForcePathStyle, keepLocalCopy: patch.keepLocalCopy ?? prev.keepLocalCopy,
lastTestAt: prev.lastTestAt ?? null, lastTestError: prev.lastTestError ?? null, updatedAt,
}
const connectionFields = ["provider", "s3Endpoint", "s3Region", "s3Bucket", "s3Prefix", "s3AccessKeyId", "s3SecretAccessKey", "s3ForcePathStyle"] as const
if (connectionFields.some(key => next[key] !== prev[key])) { next.lastTestAt = null; next.lastTestError = null }
return next
}
+22 -11
View File
@@ -65,15 +65,17 @@ export const defaultStorageForm: BackupStorageForm = {
showPassword: false,
}
function storageStatus(saved: BackupStorageSettingsDto | null, form: BackupStorageForm) {
if (form.provider === "local") {
function storageStatus(saved: BackupStorageSettingsDto | null, dirty: boolean) {
if (dirty) return { label: "Не сохранено", variant: "warning-light" as const }
if (!saved) return { label: "Демонстрация", variant: "secondary" as const }
if (saved.provider === "local") {
return { label: "Локально", variant: "secondary" as const }
}
if (saved?.lastTestError) {
return { label: "Ошибка", variant: "destructive-light" as const }
}
if (saved?.lastTestAt && !saved.lastTestError) {
return { label: "Connected", variant: "success-light" as const }
return { label: "Проверено ранее", variant: "success-light" as const }
}
if (saved?.secretConfigured && saved.s3Bucket) {
return { label: "Не проверено", variant: "warning-light" as const }
@@ -96,6 +98,7 @@ export function BackupsSettings({
onSaveStorage,
scheduleKnown = true,
storageKnown = true,
storageDirty = false,
scheduleWritable = true,
storageWritable = true,
allEnabled = false,
@@ -119,6 +122,7 @@ export function BackupsSettings({
onSaveStorage: () => void
scheduleKnown?: boolean
storageKnown?: boolean
storageDirty?: boolean
scheduleWritable?: boolean
storageWritable?: boolean
allEnabled?: boolean
@@ -130,7 +134,7 @@ export function BackupsSettings({
testBusy: boolean
syncBusy: boolean
}) {
const status = storageStatus(savedStorage, storage)
const status = storageStatus(savedStorage, storageDirty)
const secretPlaceholder = savedStorage?.secretConfigured ? "•••••••• (сохранён)" : "••••••••"
return (
@@ -260,6 +264,7 @@ export function BackupsSettings({
}
contentClassName="px-5 py-5 flex flex-col gap-5"
>
{savedStorage?.lastTestAt && <p className="text-xs text-muted-foreground">Последняя проверка: {new Date(savedStorage.lastTestAt).toLocaleString("ru-RU")}. Текущая доступность может измениться.</p>}
<FormField label="Тип хранилища">
<SegmentedControl
value={storage.provider}
@@ -281,6 +286,7 @@ export function BackupsSettings({
<Input
className="font-mono"
placeholder="https://s3.amazonaws.com"
aria-label="Endpoint"
value={storage.s3Endpoint}
onChange={(e) => onStorageChange("s3Endpoint", e.target.value)}
/>
@@ -290,7 +296,8 @@ export function BackupsSettings({
<Input
className="font-mono"
placeholder="us-east-1"
value={storage.s3Region}
aria-label="Region"
value={storage.s3Region}
onChange={(e) => onStorageChange("s3Region", e.target.value)}
/>
</FormField>
@@ -298,7 +305,8 @@ export function BackupsSettings({
<Input
className="font-mono"
placeholder="mikrotik-backups"
value={storage.s3Bucket}
aria-label="Bucket"
value={storage.s3Bucket}
onChange={(e) => onStorageChange("s3Bucket", e.target.value)}
/>
</FormField>
@@ -307,6 +315,7 @@ export function BackupsSettings({
<Input
className="font-mono"
placeholder="mikrotik"
aria-label="Prefix"
value={storage.s3Prefix}
onChange={(e) => onStorageChange("s3Prefix", e.target.value)}
/>
@@ -316,7 +325,8 @@ export function BackupsSettings({
<Input
className="font-mono"
autoComplete="off"
value={storage.s3AccessKeyId}
aria-label="Access key"
value={storage.s3AccessKeyId}
onChange={(e) => onStorageChange("s3AccessKeyId", e.target.value)}
/>
</FormField>
@@ -327,7 +337,8 @@ export function BackupsSettings({
className="font-mono pr-14"
autoComplete="new-password"
placeholder={secretPlaceholder}
value={storage.s3SecretAccessKey}
aria-label="Secret key"
value={storage.s3SecretAccessKey}
onChange={(e) => onStorageChange("s3SecretAccessKey", e.target.value)}
/>
<button
@@ -345,14 +356,14 @@ export function BackupsSettings({
<p className="text-sm font-medium">Path-style</p>
<p className="text-xs text-muted-foreground mt-0.5">Нужен для MinIO и части совместимых API</p>
</div>
<FormToggle checked={storage.s3ForcePathStyle} onChange={(v) => onStorageChange("s3ForcePathStyle", v)} />
<FormToggle ariaLabel="Path-style" checked={storage.s3ForcePathStyle} onChange={(v) => onStorageChange("s3ForcePathStyle", v)} />
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Оставлять локальную копию</p>
<p className="text-xs text-muted-foreground mt-0.5">После успешной загрузки в S3</p>
</div>
<FormToggle checked={storage.keepLocalCopy} onChange={(v) => onStorageChange("keepLocalCopy", v)} />
<FormToggle ariaLabel="Оставлять локальную копию" checked={storage.keepLocalCopy} onChange={(v) => onStorageChange("keepLocalCopy", v)} />
</div>
{savedStorage?.lastTestError ? (
<p className="text-xs text-destructive">{savedStorage.lastTestError}</p>
@@ -425,7 +436,7 @@ export function BackupsSettings({
})}
</div>
<p className="text-xs text-muted-foreground">
Выбрано {selectedServers.size} из {servers.length} серверов
{allEnabled ? "Все включённые устройства, включая добавленные позже" : "Выбрано " + selectedServers.size + " из " + servers.length + " серверов"}
</p>
</OpsPanel>
</fieldset>
+1 -1
View File
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
Актуальная точка продолжения: 11А.27.2 — валидация и подтверждение S3 настроек/теста, затем создание/checkpoint/polling и остальные операции. Расписание и independent save реализованы в 11А.27.1. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты.
Актуальная точка продолжения: 11А.28 — создание/checkpoint/polling резервных копий и подтверждение partial результата, затем остальные операции/URL/приёмка. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты.
+9 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.27.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: каталоги/API/runner/расписание; далее 11А.27.2 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.27.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: каталоги/API/runner/настройки; далее 11А.28 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11А.27.2 — достоверные настройки и последняя проверка S3 (2026-10-01)
- S3 форма валидирует обязательные Bucket/Access key/Secret key или наличие сохранённого секрета, HTTP(S) Endpoint. Пустое secret поле оставляет существующий ключ; пробельный новый ключ отклоняется. Поля и toggles имеют доступные имена. Dirty настройка имеет статус «Не сохранено», показ/скрытие секрета не считается изменением конфигурации.
- Backend сбрасывает lastTestAt/error при фактическом изменении S3-конфигурации/ключей. Test запись проверяет updatedAt через условный UPDATE; изменившаяся конфигурация требует повторной проверки. UI показывает «Проверено ранее» и дату, не Connected; API error с подтверждённым settings обновляет последний receipt. Реальный PostgreSQL optimistic UPDATE ещё требует внешней приёмки.
- Совместимость строгого backup API с PostgreSQL подтверждена mapper unit: raw timestamp преобразуется в ISO того же момента. Null server/0 byte/uploadError сохраняются; invalid дата/размер отклоняются. Сравнение timestamp receipt учитывает представление PostgreSQL, а не только строковое равенство.
- 28 data/settings/storage E2E, 11 frontend helper/API unit, 3 DTO + 3 storage update + 3 job fault unit и HTTP/S3 профиль, frontend/backend production build, TypeScript/профильный ESLint прошли. Реальные PostgreSQL/RouterOS/S3 и конкурентные внешние клиенты не проверены.
- **Следующий блок 11А.28:** single-flight создание, immutable цель/checkpoint, наблюдение без setInterval гонок и ложного полного успеха при failures/uploadError. Jobs сейчас in-memory: рестарт backend означает потерю наблюдения; автоматический повтор неизвестного POST недопустим. Затем delete/restore/download, URL, scheduler cleanup и визуальная приёмка.
## Блок 11А.27.1 — независимое сохранение настроек и расписания (2026-10-01)
- Расписание и хранилище имеют отдельные кнопки/PUT/подтверждения. Отказ одного DTO не скрывает известную соседнюю форму. Общий settings lock защищает save/test/sync от повторов; поля заблокированы на время отправки, старый GET отменяется. Unmount отменяет мутацию и подавляет поздние toast/state.
+8
View File
@@ -337,3 +337,11 @@
- API подтверждает отправленные поля и наличие нового секретного ключа. Ошибка остаётся в постоянном Alert/черновике; только подтверждённый save очищает secret. Test/sync S3 используют сохранённую конфигурацию и не делают скрытый PUT; dirty форма просит явно сохранить её.
- 10 helper/API unit, 12 data + 9 settings E2E (повтор после исправления a11y имени checkbox), 8 context E2E, полный TypeScript/профильный ESLint и production frontend прошли. Реальные PostgreSQL/RouterOS/S3 не проверены.
- **Следующий блок 11А.27.2:** валидация S3/доступные имена полей, корректная свежесть последнего теста при изменении конфигурации, секрет/невалидный ack/API error/test без PUT и профильная проверка. Затем создание/checkpoint/polling, остальные операции и визуальная приёмка.
## Блок 11А.27.2 — достоверные настройки и последняя проверка S3 (2026-10-01)
- S3 форма валидирует обязательные Bucket/Access key/Secret key или наличие сохранённого секрета, HTTP(S) Endpoint. Пустое secret поле оставляет существующий ключ; пробельный новый ключ отклоняется. Поля и toggles имеют доступные имена. Dirty настройка имеет статус «Не сохранено», показ/скрытие секрета не считается изменением конфигурации.
- Backend сбрасывает lastTestAt/error при фактическом изменении S3-конфигурации/ключей. Test запись проверяет updatedAt через условный UPDATE; изменившаяся конфигурация требует повторной проверки. UI показывает «Проверено ранее» и дату, не Connected; API error с подтверждённым settings обновляет последний receipt. Реальный PostgreSQL optimistic UPDATE ещё требует внешней приёмки.
- Совместимость строгого backup API с PostgreSQL подтверждена mapper unit: raw timestamp преобразуется в ISO того же момента. Null server/0 byte/uploadError сохраняются; invalid дата/размер отклоняются. Сравнение timestamp receipt учитывает представление PostgreSQL, а не только строковое равенство.
- 28 data/settings/storage E2E, 11 frontend helper/API unit, 3 DTO + 3 storage update + 3 job fault unit и HTTP/S3 профиль, frontend/backend production build, TypeScript/профильный ESLint прошли. Реальные PostgreSQL/RouterOS/S3 и конкурентные внешние клиенты не проверены.
- **Следующий блок 11А.28:** single-flight создание, immutable цель/checkpoint, наблюдение без setInterval гонок и ложного полного успеха при failures/uploadError. Jobs сейчас in-memory: рестарт backend означает потерю наблюдения; автоматический повтор неизвестного POST недопустим. Затем delete/restore/download, URL, scheduler cleanup и визуальная приёмка.
+9 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { backupSchedulePayload } from "./backup-settings"
import { backupSchedulePayload, backupStoragePayload } from "./backup-settings"
const form = { enabled: false, frequency: "daily" as const, hour: "0", minute: "7", weekDay: 0, monthDay: 1, keepCount: "365", format: "rsc" as const }
test("schedule preserves zero, arbitrary minute and explicit all-enabled semantics", () => {
const result = backupSchedulePayload(form, ["1"], true)
@@ -16,3 +16,11 @@ test("unsupported binary schedule requires an explicit format choice", () => {
assert.throws(() => backupSchedulePayload({ ...form, enabled: true, format: "backup" }, ["1"], false))
assert.equal(backupSchedulePayload({ ...form, format: "backup" }, ["1"], false).format, "backup")
})
const storageForm = { provider: "s3" as const, s3Endpoint: "", s3Region: "", s3Bucket: "bucket", s3Prefix: "", s3AccessKeyId: "key", s3SecretAccessKey: "", s3ForcePathStyle: false, keepLocalCopy: false, showPassword: false }
test("S3 requires complete fields, endpoint protocol and a new or existing secret", () => {
assert.throws(() => backupStoragePayload(storageForm))
assert.equal(backupStoragePayload(storageForm, true).s3SecretAccessKey, undefined)
assert.equal(backupStoragePayload({ ...storageForm, s3SecretAccessKey: "new-test-secret" }).s3SecretAccessKey, "new-test-secret")
for (const patch of [{ s3Bucket: " " }, { s3AccessKeyId: " " }, { s3Endpoint: "invalid" }, { s3Endpoint: "ftp://example.test" }, { s3SecretAccessKey: " " }]) assert.throws(() => backupStoragePayload({ ...storageForm, ...patch }, true))
})
+11 -1
View File
@@ -12,6 +12,16 @@ export function backupSchedulePayload(form: BackupScheduleForm, serverIds: strin
if (form.enabled && form.format !== "rsc") throw new Error("Плановый binary backup пока не поддерживается; выберите .rsc явно")
return putBackupScheduleSettingsSchema.parse({ ...form, hour: integer(form.hour, "Час", 0, 23), minute: integer(form.minute, "Минута", 0, 59), weekDay: integer(form.weekDay, "День недели", 0, 6), monthDay: integer(form.monthDay, "День месяца", 1, 28), keepCount: integer(form.keepCount, "Число копий", 1, 365), serverIds: allEnabled ? [] : serverIds })
}
export function backupStoragePayload(form: BackupStorageForm) {
export function backupStoragePayload(form: BackupStorageForm, secretConfigured = false) {
if (form.provider === "s3") {
if (!form.s3Bucket.trim() || !form.s3AccessKeyId.trim()) throw new Error("Для S3 заполните Bucket и Access key")
if (!form.s3SecretAccessKey.trim() && !secretConfigured) throw new Error("Для S3 требуется Secret key")
if (form.s3SecretAccessKey && !form.s3SecretAccessKey.trim()) throw new Error("Secret key не может состоять только из пробелов")
if (form.s3Endpoint) {
let endpoint: URL
try { endpoint = new URL(form.s3Endpoint) } catch { throw new Error("Endpoint должен быть полным URL http/https") }
if (!["http:", "https:"].includes(endpoint.protocol)) throw new Error("Endpoint должен использовать http/https")
}
}
return putBackupStorageSettingsSchema.parse({ provider: form.provider, s3Endpoint: form.s3Endpoint, s3Region: form.s3Region, s3Bucket: form.s3Bucket, s3Prefix: form.s3Prefix, s3AccessKeyId: form.s3AccessKeyId, s3SecretAccessKey: form.s3SecretAccessKey || undefined, s3ForcePathStyle: form.s3ForcePathStyle, keepLocalCopy: form.keepLocalCopy })
}
+56
View File
@@ -0,0 +1,56 @@
import { test, expect, type Page } from "@playwright/test"
import { setupBackups, storage } from "./fixtures/backups"
const s3 = { ...storage, provider: "s3", s3Bucket: "bucket", s3AccessKeyId: "test-access", secretConfigured: true, lastTestAt: "2026-10-01T00:00:00Z", lastTestError: null }
async function open(page: Page) { await page.goto("/backups"); await page.getByRole("tab", { name: "Настройки", exact: true }).click(); await expect(page.getByRole("button", { name: "Сохранить хранилище", exact: true })).toBeEnabled() }
const save = (page: Page) => page.getByRole("button", { name: "Сохранить хранилище", exact: true })
for (const failure of ["API", "malformed", "unconfirmed secret"]) test("Бэкапы S3: " + failure + " сохраняет секрет и черновик", async ({ page }) => {
await setupBackups(page); let puts = 0
await page.route("**/api/backups/storage", r => {
if (r.request().method() !== "PUT") return r.fulfill({ json: s3 })
puts++; const body = r.request().postDataJSON()
return r.fulfill(failure === "API" ? { status: 503, json: { error: "S3 save failed" } } : { json: failure === "malformed" ? {} : { ...s3, ...body, secretConfigured: false } })
})
await open(page); await page.getByRole("textbox", { name: "Bucket", exact: true }).fill("draft-bucket")
await page.getByLabel("Secret key", { exact: true }).fill("new-test-secret")
await save(page).click(); await expect(save(page)).toBeEnabled(); expect(puts).toBe(1)
await expect(page.getByLabel("Secret key", { exact: true })).toHaveValue("new-test-secret")
await expect(page.getByRole("textbox", { name: "Bucket", exact: true })).toHaveValue("draft-bucket")
await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toBeVisible()
await expect(page.getByText("Хранилище сохранено", { exact: true })).toHaveCount(0)
})
test("Бэкапы S3: подтверждённый secret save очищает только новый секрет", async ({ page }) => {
await setupBackups(page); let body: Record<string, unknown> = {}
await page.route("**/api/backups/storage", r => {
if (r.request().method() !== "PUT") return r.fulfill({ json: s3 })
body = r.request().postDataJSON(); return r.fulfill({ json: { ...s3, ...body, secretConfigured: true, lastTestAt: null, lastTestError: null } })
})
await open(page); await page.getByLabel("Secret key", { exact: true }).fill("new-test-secret"); await save(page).click()
await expect(page.getByText("Хранилище сохранено", { exact: true })).toBeVisible(); await expect(page.getByLabel("Secret key", { exact: true })).toHaveValue("")
expect(body.s3SecretAccessKey).toBe("new-test-secret"); await expect(page.getByText("Проверено ранее", { exact: true })).toHaveCount(0)
})
test("Бэкапы S3: незаполненный S3 и неверный Endpoint не отправляются", async ({ page }) => {
await setupBackups(page); let puts = 0
await page.route("**/api/backups/storage", r => { if (r.request().method() === "PUT") puts++; return r.fallback() })
await open(page); await page.getByRole("button", { name: "S3", exact: true }).click(); await save(page).click()
await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("Bucket")
await page.getByLabel("Bucket", { exact: true }).fill("bucket"); await page.getByLabel("Access key", { exact: true }).fill("test-access"); await page.getByLabel("Secret key", { exact: true }).fill("test-secret")
await page.getByLabel("Endpoint", { exact: true }).fill("ftp://example.test"); await save(page).click()
await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("http/https"); expect(puts).toBe(0)
})
test("Бэкапы S3: проверка подтверждённого хранилища делает только POST test", async ({ page }) => {
await setupBackups(page); let puts = 0, tests = 0
await page.route("**/api/backups/storage", r => { if (r.request().method() === "PUT") puts++; return r.fulfill({ json: s3 }) })
await page.route("**/api/backups/storage/test", r => { tests++; return r.fulfill({ json: { ...s3, lastTestAt: "2026-10-01T01:00:00Z" } }) })
await open(page); await expect(page.getByText("Проверено ранее", { exact: true })).toBeVisible()
await expect(page.getByText("Connected", { exact: true })).toHaveCount(0)
await page.getByRole("button", { name: "Проверить", exact: true }).click()
await expect(page.getByText("Доступ к S3 подтверждён", { exact: true })).toBeVisible(); expect(tests).toBe(1); expect(puts).toBe(0)
})
test("Бэкапы S3: отказ теста показывает backend receipt, не старый успех", async ({ page }) => {
await setupBackups(page); await page.route("**/api/backups/storage", r => r.fulfill({ json: s3 }))
await page.route("**/api/backups/storage/test", r => r.fulfill({ status: 400, json: { error: "S3 denied", settings: { ...s3, lastTestAt: "2026-10-01T01:00:00Z", lastTestError: "S3 denied" } } }))
await open(page); await page.getByRole("button", { name: "Проверить", exact: true }).click()
await expect(page.getByRole("alert", { name: "Операция резервных копий" })).toContainText("S3 denied")
await expect(page.getByText("Проверено ранее", { exact: true })).toHaveCount(0)
await expect(page.getByText("Доступ к S3 подтверждён", { exact: true })).toHaveCount(0)
})