fix(backups): подтверждать настройки и последнюю проверку S3
This commit is contained in:
1 parent
8e01666e0e
commit
30bd479a6f
14 files changed
+208
-64
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }))
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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А ещё не приняты.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 и визуальная приёмка.
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user