diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index d75f85c2..09be9267 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -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(defaultSchedule) const [storage, setStorage] = useState(defaultStorageForm) + const [storageIsDirty, setStorageIsDirty] = useState(false) const [savedSchedule, setSavedSchedule] = useState(null) const [savedStorage, setSavedStorage] = useState(null) const [scheduleLastError, setScheduleLastError] = useState(null) @@ -108,7 +109,7 @@ function BackupsContent() { const [restoreTarget, setRestoreTarget] = useState(null) const setSched = (k: K, v: BackupScheduleForm[K]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) } - const setStore = (k: K, v: BackupStorageForm[K]) => { storageDirty.current = true; setStorage(s => ({ ...s, [k]: v })) } + const setStore = (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} diff --git a/backend/package.json b/backend/package.json index 616bd691..63dbe5a5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/services/backup-dto.test.ts b/backend/src/services/backup-dto.test.ts new file mode 100644 index 00000000..aec25de8 --- /dev/null +++ b/backend/src/services/backup-dto.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 })) +}) diff --git a/backend/src/services/backup-dto.ts b/backend/src/services/backup-dto.ts new file mode 100644 index 00000000..485f7b6c --- /dev/null +++ b/backend/src/services/backup-dto.ts @@ -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, + }) +} diff --git a/backend/src/services/backup-service.ts b/backend/src/services/backup-service.ts index 52ba82a9..a2a9e4b9 100644 --- a/backend/src/services/backup-service.ts +++ b/backend/src/services/backup-service.ts @@ -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 { await mkdir(BACKUPS_DIR, { recursive: true }) @@ -260,19 +248,7 @@ export async function updateBackupStorageSettings( ): Promise { 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 { + 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") +}) diff --git a/backend/src/services/backup-storage-update.ts b/backend/src/services/backup-storage-update.ts new file mode 100644 index 00000000..535a2365 --- /dev/null +++ b/backend/src/services/backup-storage-update.ts @@ -0,0 +1,15 @@ +import type { BackupStorageSettingsDto, PutBackupStorageSettings } from "@mmapp/contracts/backups" +type StorageRecord = Omit & { 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 +} diff --git a/components/backups/backups-settings.tsx b/components/backups/backups-settings.tsx index ae3c7444..9522a200 100644 --- a/components/backups/backups-settings.tsx +++ b/components/backups/backups-settings.tsx @@ -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 &&

Последняя проверка: {new Date(savedStorage.lastTestAt).toLocaleString("ru-RU")}. Текущая доступность может измениться.

} onStorageChange("s3Endpoint", e.target.value)} /> @@ -290,7 +296,8 @@ export function BackupsSettings({ onStorageChange("s3Region", e.target.value)} /> @@ -298,7 +305,8 @@ export function BackupsSettings({ onStorageChange("s3Bucket", e.target.value)} /> @@ -307,6 +315,7 @@ export function BackupsSettings({ onStorageChange("s3Prefix", e.target.value)} /> @@ -316,7 +325,8 @@ export function BackupsSettings({ onStorageChange("s3AccessKeyId", e.target.value)} /> @@ -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)} />