From cff26813b91fa5a581e28f3480e3074801ce3544 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 8 Sep 2026 14:06:27 +0700 Subject: [PATCH] =?UTF-8?q?feat(backups):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20S3-=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D1=89=D0=B5=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=B1?= =?UTF-8?q?=D1=8D=D0=BA=D0=B0=D0=BF=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами. Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление. Co-authored-by: Cursor --- app/(main)/backups/page.tsx | 892 ++++++------------ backend/drizzle/0003_backup_s3.sql | 44 + backend/package.json | 4 +- backend/src/db/schema.ts | 20 + backend/src/db/sqlite-import.ts | 6 + backend/src/routes/backups.ts | 81 +- backend/src/services/backup-service.ts | 313 +++++- backend/src/services/mikrotik.ts | 8 + backend/src/services/s3-backup-client.test.ts | 89 ++ backend/src/services/s3-backup-client.ts | 128 +++ components/backups/backup-confirm-dialogs.tsx | 97 ++ components/backups/backup-create-sheet.tsx | 199 ++++ components/backups/backups-history.tsx | 97 ++ components/backups/backups-settings.tsx | 427 +++++++++ components/data-grids/backups-data-grid.tsx | 102 +- components/data-page-toolbar.tsx | 9 +- lib/data-filters/backup-filter-fields.ts | 36 + lib/data.ts | 13 +- package-lock.json | 411 ++++++++ packages/contracts/src/backups.ts | 47 + shared/api/backups.ts | 43 +- 21 files changed, 2429 insertions(+), 637 deletions(-) create mode 100644 backend/drizzle/0003_backup_s3.sql create mode 100644 backend/src/services/s3-backup-client.test.ts create mode 100644 backend/src/services/s3-backup-client.ts create mode 100644 components/backups/backup-confirm-dialogs.tsx create mode 100644 components/backups/backup-create-sheet.tsx create mode 100644 components/backups/backups-history.tsx create mode 100644 components/backups/backups-settings.tsx create mode 100644 lib/data-filters/backup-filter-fields.ts diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index 68c2e37..54d7654 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -1,167 +1,100 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { PageHeader } from "@/components/page-header" -import { DataPageToolbar } from "@/components/data-page-toolbar" -import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid" -import { FileImportDialog } from "@/components/file-import-dialog" -import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit" -import { StatusBadge } from "@/components/status-badge" import type { Backup, Server } from "@/lib/data" +import type { Filter } from "@/components/reui/filters" +import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" -import { OpsPanel } from "@/components/ops-panel" -import { DataPageCard } from "@/components/data-page-card" +import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { - Sheet, SheetContent, SheetHeader, SheetTitle, - SheetDescription, SheetFooter, SheetClose, -} from "@/components/ui/sheet" -import { - RefreshCwIcon, PlusIcon, DownloadIcon, Trash2Icon, - HardDriveIcon, ClockIcon, ServerIcon, - FolderIcon, + CloudIcon, + ClockIcon, + HardDriveIcon, + PlusIcon, + RefreshCwIcon, } from "lucide-react" -import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" import { listServers } from "@/shared/api/servers" import { toFrontendServer } from "@/entities/server/model/mappers" -import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups" +import { + createBackupsAsync, + deleteBackup, + getBackupJob, + getBackupScheduleSettings, + getBackupStorageSettings, + listBackups, + putBackupScheduleSettings, + putBackupStorageSettings, + restoreBackup, + syncBackupsFromStorage, + testBackupStorage, + type BackupItem, +} from "@/shared/api/backups" import { requestBlob } from "@/shared/api/http-client" import { toast } from "sonner" +import { BackupsHistory } from "@/components/backups/backups-history" import { - Stepper, - StepperContent, - StepperIndicator, - StepperItem, - StepperNav, - StepperPanel, - StepperSeparator, - StepperTitle, - StepperTrigger, -} from "@/components/reui/stepper" - -// ─── types ──────────────────────────────────────────────────────────────────── + BackupsSettings, + WEEK_DAYS, + defaultSchedule, + defaultStorageForm, + type BackupScheduleForm, + type BackupStorageForm, +} from "@/components/backups/backups-settings" +import { BackupCreateSheet } from "@/components/backups/backup-create-sheet" +import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs" type PageTab = "history" | "settings" type KindFilter = "all" | "auto" | "manual" -type BackupFreq = "daily" | "weekly" | "monthly" -type StorageType = "local" | "ftp" | "scp" | "smb" -type BackupFormat = "rsc" | "backup" -const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] - -// ─── defaults ───────────────────────────────────────────────────────────────── - -const defaultSchedule = { - enabled: true, - frequency: "daily" as BackupFreq, - hour: 3, - minute: 0, - weekDay: 0, - monthDay: 1, - keepCount: 7, - format: "rsc" as BackupFormat, -} - -const defaultStorage = { - type: "local" as StorageType, - localPath: "/var/backup/mikrotik", - host: "", - port: "", - username: "", - password: "", - remotePath: "/mikrotik-backups", - share: "backups", - showPassword: false, -} - -// ════════════════════════════════════════════════════════════════════════════ export default function BackupsPage() { const { backendUrl } = useDataSource() - // ── state ────────────────────────────────────────────────────────────── - const [tab, setTab] = useState("history") + const [tab, setTab] = useState("history") const [backupList, setBackupList] = useState([]) const [kindFilter, setKindFilter] = useState("all") + const [search, setSearch] = useState("") + const [advancedFilters, setAdvancedFilters] = useState([]) const [liveServers, setLiveServers] = useState([]) const [loading, setLoading] = useState(false) const [opBusy, setOpBusy] = useState(false) const [opError, setOpError] = useState(null) const [backupJobId, setBackupJobId] = useState(null) const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false) + const [testBusy, setTestBusy] = useState(false) + const [syncBusy, setSyncBusy] = useState(false) + const [schedule, setSchedule] = useState(defaultSchedule) + const [storage, setStorage] = useState(defaultStorageForm) + const [savedStorage, setSavedStorage] = useState(null) + const [scheduleLastError, setScheduleLastError] = useState(null) + const [selectedServers, setSelectedServers] = useState>(new Set()) + const [manualOpen, setManualOpen] = useState(false) + const [manualStep, setManualStep] = useState(1) + const [manualServers, setManualServers] = useState>(new Set()) + const [manualNotes, setManualNotes] = useState("") + const [deleteTarget, setDeleteTarget] = useState(null) + const [restoreTarget, setRestoreTarget] = useState(null) - // Schedule - const [schedule, setSchedule] = useState(defaultSchedule) - const setSched = (k: K, v: (typeof defaultSchedule)[K]) => + const setSched = (k: K, v: BackupScheduleForm[K]) => setSchedule((s) => ({ ...s, [k]: v })) - - // Storage - const [storage, setStorage] = useState(defaultStorage) - const setStore = (k: K, v: (typeof defaultStorage)[K]) => + const setStore = (k: K, v: BackupStorageForm[K]) => setStorage((s) => ({ ...s, [k]: v })) - // Server selection (all enabled by default) - const [selectedServers, setSelectedServers] = useState>( - new Set() - ) - function toggleServer(id: string) { - setSelectedServers((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id); else next.add(id) - return next - }) - } - - function handleSave() { - if (scheduleSaveBusy) return - setScheduleSaveBusy(true) - setOpError(null) - void (async () => { - try { - await putBackupScheduleSettings(backendUrl, { - enabled: schedule.enabled, - frequency: schedule.frequency, - hour: schedule.hour, - minute: schedule.minute, - weekDay: schedule.weekDay, - monthDay: schedule.monthDay, - keepCount: schedule.keepCount, - format: schedule.format, - serverIds: [...selectedServers], - }) - toast.success("Настройки сохранены") - } catch (e) { - setOpError(e instanceof Error ? e.message : "Ошибка сохранения расписания") - } finally { - setScheduleSaveBusy(false) - } - })() - } - - // Manual backup sheet - const [manualOpen, setManualOpen] = useState(false) - const [manualStep, setManualStep] = useState(1) - const [manualServers, setManualServers] = useState>(new Set()) - const [manualNotes, setManualNotes] = useState("") - const [restoreOpen, setRestoreOpen] = useState(false) - const [restoreTarget, setRestoreTarget] = useState(null) - function toggleManualServer(id: string) { - setManualServers((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id); else next.add(id) - return next - }) - } const mapApiBackupToUi = useCallback((b: BackupItem): Backup => { const kb = Math.max(1, Math.round(b.sizeBytes / 1024)) return { id: b.id, server: b.serverName, + serverId: b.serverId == null ? null : String(b.serverId), filename: b.filename, size: `${kb} КБ`, created: new Date(b.createdAt).toLocaleString("ru-RU"), kind: b.kind, notes: b.notes ?? "", + storage: b.storage ?? "local", + uploadError: b.uploadError ?? undefined, } }, []) @@ -169,10 +102,11 @@ export default function BackupsPage() { setLoading(true) setOpError(null) try { - const [serversRows, backupsRows, scheduleRow] = await Promise.all([ + const [serversRows, backupsRows, scheduleRow, storageRow] = await Promise.all([ listServers(backendUrl), listBackups(backendUrl), getBackupScheduleSettings(backendUrl), + getBackupStorageSettings(backendUrl), ]) const mappedServers = serversRows.map((s) => toFrontendServer(s)) setLiveServers(mappedServers) @@ -187,6 +121,20 @@ export default function BackupsPage() { keepCount: scheduleRow.keepCount, format: scheduleRow.format, }) + setSavedStorage(storageRow) + setScheduleLastError(scheduleRow.lastError ?? null) + setStorage((prev) => ({ + ...prev, + provider: storageRow.provider, + s3Endpoint: storageRow.s3Endpoint, + s3Region: storageRow.s3Region, + s3Bucket: storageRow.s3Bucket, + s3Prefix: storageRow.s3Prefix, + s3AccessKeyId: storageRow.s3AccessKeyId, + s3SecretAccessKey: "", + s3ForcePathStyle: storageRow.s3ForcePathStyle, + keepLocalCopy: storageRow.keepLocalCopy, + })) const serverIds = scheduleRow.serverIds.length > 0 ? scheduleRow.serverIds : mappedServers.map((s) => s.id) @@ -202,6 +150,85 @@ export default function BackupsPage() { void loadLive() }, [loadLive]) + async function handleSave() { + if (scheduleSaveBusy) return + setScheduleSaveBusy(true) + setOpError(null) + try { + await putBackupScheduleSettings(backendUrl, { + enabled: schedule.enabled, + frequency: schedule.frequency, + hour: schedule.hour, + minute: schedule.minute, + weekDay: schedule.weekDay, + monthDay: schedule.monthDay, + keepCount: schedule.keepCount, + format: "rsc", + serverIds: [...selectedServers], + }) + const saved = await putBackupStorageSettings(backendUrl, { + provider: storage.provider, + s3Endpoint: storage.s3Endpoint, + s3Region: storage.s3Region, + s3Bucket: storage.s3Bucket, + s3Prefix: storage.s3Prefix, + s3AccessKeyId: storage.s3AccessKeyId, + s3SecretAccessKey: storage.s3SecretAccessKey || undefined, + s3ForcePathStyle: storage.s3ForcePathStyle, + keepLocalCopy: storage.keepLocalCopy, + }) + setSavedStorage(saved) + setStorage((prev) => ({ ...prev, s3SecretAccessKey: "" })) + toast.success("Настройки сохранены") + } catch (e) { + setOpError(e instanceof Error ? e.message : "Ошибка сохранения настроек") + } finally { + setScheduleSaveBusy(false) + } + } + + async function handleTestStorage() { + setTestBusy(true) + try { + await putBackupStorageSettings(backendUrl, { + provider: storage.provider, + s3Endpoint: storage.s3Endpoint, + s3Region: storage.s3Region, + s3Bucket: storage.s3Bucket, + s3Prefix: storage.s3Prefix, + s3AccessKeyId: storage.s3AccessKeyId, + s3SecretAccessKey: storage.s3SecretAccessKey || undefined, + s3ForcePathStyle: storage.s3ForcePathStyle, + keepLocalCopy: storage.keepLocalCopy, + }) + const result = await testBackupStorage(backendUrl) + setSavedStorage(result) + toast.success("S3 доступен") + } catch (e) { + toast.error(e instanceof Error ? e.message : "Ошибка проверки S3") + try { + setSavedStorage(await getBackupStorageSettings(backendUrl)) + } catch { + /* ignore */ + } + } finally { + setTestBusy(false) + } + } + + async function handleSyncStorage() { + setSyncBusy(true) + try { + const result = await syncBackupsFromStorage(backendUrl) + toast.success(`Импортировано: ${result.imported}, пропущено: ${result.skipped}`) + await loadLive() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Ошибка синхронизации") + } finally { + setSyncBusy(false) + } + } + async function handleManualBackup() { if (manualServers.size === 0 || opBusy) return setOpError(null) @@ -261,12 +288,13 @@ export default function BackupsPage() { if (opError) toast.error(opError) }, [opError]) - // Delete backup - async function handleDelete(id: string) { + async function handleDelete() { + if (!deleteTarget) return setOpBusy(true) setOpError(null) try { - await deleteBackup(backendUrl, id) + await deleteBackup(backendUrl, deleteTarget.id) + setDeleteTarget(null) await loadLive() } catch (e) { setOpError(e instanceof Error ? e.message : "Ошибка удаления бэкапа") @@ -275,6 +303,21 @@ export default function BackupsPage() { } } + async function handleRestore() { + if (!restoreTarget) return + setOpBusy(true) + setOpError(null) + try { + await restoreBackup(backendUrl, restoreTarget.id) + toast.success(`Конфигурация восстановлена на ${restoreTarget.server}`) + setRestoreTarget(null) + } catch (e) { + setOpError(e instanceof Error ? e.message : "Ошибка восстановления") + } finally { + setOpBusy(false) + } + } + async function handleDownload(id: string, fallbackFilename: string) { const res = await requestBlob(backendUrl, `/api/backups/${id}/download`) const blob = await res.blob() @@ -286,31 +329,41 @@ export default function BackupsPage() { URL.revokeObjectURL(url) } - // ── derived ──────────────────────────────────────────────────────────── - const filtered = useMemo(() => - backupList.filter((b) => kindFilter === "all" || b.kind === kindFilter), - [backupList, kindFilter] - ) - - const autoCount = backupList.filter((b) => b.kind === "auto").length + const autoCount = backupList.filter((b) => b.kind === "auto").length const manualCount = backupList.filter((b) => b.kind === "manual").length - const serverCount = new Set(backupList.map((b) => b.server)).size + const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length - // Schedule summary string const schedSummary = (() => { - if (!schedule.enabled) return "Отключено" - const t = `${String(schedule.hour).padStart(2,"0")}:${String(schedule.minute).padStart(2,"0")}` - if (schedule.frequency === "daily") return `Каждый день в ${t}` - if (schedule.frequency === "weekly") return `Каждую неделю (${WEEK_DAYS[schedule.weekDay]}) в ${t}` + if (!schedule.enabled) return "Расписание отключено" + const t = `${String(schedule.hour).padStart(2, "0")}:${String(schedule.minute).padStart(2, "0")}` + if (schedule.frequency === "daily") return `Каждый день в ${t}` + if (schedule.frequency === "weekly") return `Каждую неделю (${WEEK_DAYS[schedule.weekDay]}) в ${t}` return `${schedule.monthDay}-го числа каждого месяца в ${t}` })() - // Storage path summary - const pathSummary = storage.type === "local" - ? storage.localPath || "/var/backup/mikrotik" - : `${storage.type.toUpperCase()}://${storage.host || "host"}${storage.remotePath || "/"}` + const destinationLabel = storage.provider === "s3" + ? (storage.keepLocalCopy ? "локально и в S3" : "в S3") + : "локально (storage/backups)" + + const statusDetail = [ + backupJobId ? null : `${destinationLabel} · ${selectedServers.size} из ${liveServers.length} серверов`, + scheduleLastError, + savedStorage?.lastTestError, + ].filter(Boolean).join(" · ") + + const alertVariant = backupJobId + ? "info" + : opError || scheduleLastError || savedStorage?.lastTestError + ? "warning" + : "default" + + function openCreate(all: boolean) { + setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set()) + setManualNotes("") + setManualStep(1) + setManualOpen(true) + } - // ── render ───────────────────────────────────────────────────────────── return (
} @@ -341,7 +395,6 @@ export default function BackupsPage() {
- , + id: "s3", + label: "В S3", + value: s3Count, + icon: , iconClassName: "text-primary", }, ]} /> - {/* Info bar */} -
- - - {schedSummary} - - - - - {pathSummary} - - - - - {selectedServers.size} из {liveServers.length} серверов - - -
- {/* Tabs */} -
- {([ - { id: "history", label: "История бэкапов" }, - { id: "settings", label: "Настройки" }, - ] as { id: PageTab; label: string }[]).map((t) => ( - - ))} -
+ + + + {backupJobId ? "Задача выполняется" : schedSummary} + + + {backupJobId + ? "Снимок конфигурации снимается в фоне. Список обновится после завершения." + : statusDetail} + + + + + - {/* ── История ──────────────────────────────────────────────────── */} - {tab === "history" && ( - - - setTab(String(v) as PageTab)} + className="gap-5" + > + + История бэкапов + Настройки + + + { - setRestoreTarget(b) - setRestoreOpen(true) - }} - onDelete={handleDelete} + onRestore={setRestoreTarget} + onDelete={setDeleteTarget} + onCreate={() => openCreate(false)} /> - - )} - - {/* ── Настройки ────────────────────────────────────────────────── */} - {tab === "settings" && ( -
- - {/* Расписание */} - - -
-
-

Автоматический бэкап

-

Создавать бэкапы по расписанию

-
- setSched("enabled", v)} /> -
- -
- - setSched("frequency", v)} - options={[ - { value: "daily", label: "Ежедневно" }, - { value: "weekly", label: "Еженедельно" }, - { value: "monthly", label: "Ежемесячно" }, - ]} - /> - - - {schedule.frequency === "weekly" && ( - -
- {WEEK_DAYS.map((d, i) => ( - - ))} -
-
- )} - - {schedule.frequency === "monthly" && ( - - setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} /> - - )} - - -
-
- setSched("hour", Math.min(23, Math.max(0, Number(e.target.value))))} /> -
- : -
- {[0, 15, 30, 45].map((m) => ( - - ))} -
-
-
- -
- - setSched("keepCount", Math.max(1, Number(e.target.value)))} /> - - - setSched("format", v)} - options={[ - { value: "rsc", label: ".rsc" }, - { value: "backup", label: ".backup" }, - ]} - /> - -
-
-
- - {/* Хранилище */} - - - - setStore("type", v)} - options={[ - { value: "local", label: "Локально" }, - { value: "ftp", label: "FTP" }, - { value: "scp", label: "SCP" }, - { value: "smb", label: "SMB" }, - ]} - /> - - - {storage.type === "local" && ( - - setStore("localPath", e.target.value)} /> - - )} - - {storage.type !== "local" && ( - <> -
-
- - setStore("host", e.target.value)} /> - -
- - setStore("port", e.target.value)} /> - -
- - {storage.type === "smb" && ( - - setStore("share", e.target.value)} /> - - )} - -
- - setStore("username", e.target.value)} /> - - -
- setStore("password", e.target.value)} - /> - -
-
-
- - - setStore("remotePath", e.target.value)} /> - - - )} - - {/* Preview */} -
-

Путь сохранения

-

{pathSummary}

- {storage.type !== "local" && ( -

Файлы: {pathSummary}/{""}_{"{date}"}.{schedule.format}

- )} - {storage.type === "local" && ( -

Файлы: {storage.localPath || "/var/backup/mikrotik"}/{""}_{"{date}"}.{schedule.format}

- )} -
-
- - {/* Серверы */} - - - · - -
- } - contentClassName="px-5 py-5 flex flex-col gap-4" - > - -
- {liveServers.map((s) => { - const checked = selectedServers.has(s.id) - return ( - - ) - })} -
- -

- Выбрано {selectedServers.size} из {liveServers.length} серверов -

- - - {/* Save button */} -
- -
-
- )} - + + + { + setSelectedServers((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }} + onSelectAll={() => setSelectedServers(new Set(liveServers.map((s) => s.id)))} + onClearServers={() => setSelectedServers(new Set())} + onSave={handleSave} + onTest={handleTestStorage} + onSync={handleSyncStorage} + saveBusy={scheduleSaveBusy} + testBusy={testBusy} + syncBusy={syncBusy} + /> + +
- {/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */} - { setManualOpen(v); if (!v) setManualStep(1) }}> - - - Новый бэкап - Снять конфигурацию вручную с выбранных серверов - - - - - - - 1 - Серверы - - - - - - 2 - Заметка - - - - - - 3 - Подтверждение - - - - - -
-

Выберите серверы

-
- - · - -
-
- {liveServers.map((s) => { - const checked = manualServers.has(s.id) - return ( - - ) - })} -
- - - setManualNotes(e.target.value)} /> - - - -

- Будет создан бэкап для {manualServers.size} серверов. -

- {manualNotes && ( -

Заметка: {manualNotes}

- )} -
-
-
- - - }>Отмена - {manualStep > 1 && ( - - )} - {manualStep < 3 ? ( - - ) : ( - - )} - -
-
- - { - toast.success(`Файл ${files[0]?.name} подготовлен к восстановлению на ${restoreTarget?.server ?? "сервер"}`) + { + setManualServers((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) }} + onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))} + onClear={() => setManualServers(new Set())} + notes={manualNotes} + onNotesChange={setManualNotes} + destinationLabel={destinationLabel} + busy={backupJobId !== null} + onSubmit={handleManualBackup} + /> + + void handleDelete()} + onCancel={() => setDeleteTarget(null)} + /> + void handleRestore()} + onCancel={() => setRestoreTarget(null)} /> ) diff --git a/backend/drizzle/0003_backup_s3.sql b/backend/drizzle/0003_backup_s3.sql new file mode 100644 index 0000000..dad88a6 --- /dev/null +++ b/backend/drizzle/0003_backup_s3.sql @@ -0,0 +1,44 @@ +-- S3-compatible storage for RouterOS backups + +CREATE TABLE IF NOT EXISTS backup_storage_settings ( + id BIGINT PRIMARY KEY CHECK (id = 1), + provider TEXT NOT NULL DEFAULT 'local' CHECK (provider IN ('local', 's3')), + s3_endpoint TEXT NOT NULL DEFAULT '', + s3_region TEXT NOT NULL DEFAULT 'us-east-1', + s3_bucket TEXT NOT NULL DEFAULT '', + s3_prefix TEXT NOT NULL DEFAULT 'mikrotik', + s3_access_key_id TEXT NOT NULL DEFAULT '', + s3_secret_access_key TEXT NOT NULL DEFAULT '', + s3_force_path_style BOOLEAN NOT NULL DEFAULT TRUE, + keep_local_copy BOOLEAN NOT NULL DEFAULT TRUE, + last_test_at TIMESTAMPTZ, + last_test_error TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO backup_storage_settings (id) +VALUES (1) +ON CONFLICT (id) DO NOTHING; + +ALTER TABLE backup_entries + ADD COLUMN IF NOT EXISTS storage TEXT NOT NULL DEFAULT 'local'; + +ALTER TABLE backup_entries + ADD COLUMN IF NOT EXISTS s3_key TEXT; + +ALTER TABLE backup_entries + ADD COLUMN IF NOT EXISTS s3_etag TEXT; + +ALTER TABLE backup_entries + ADD COLUMN IF NOT EXISTS upload_error TEXT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'backup_entries_storage_check' + ) THEN + ALTER TABLE backup_entries + ADD CONSTRAINT backup_entries_storage_check + CHECK (storage IN ('local', 's3', 'both')); + END IF; +END $$; diff --git a/backend/package.json b/backend/package.json index 43b42c1..8a1c704 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,9 +18,11 @@ "test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts", "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts", "test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts", - "test": "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" + "test:backups": "tsx src/services/s3-backup-client.test.ts", + "test": "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" }, "dependencies": { + "@aws-sdk/client-s3": "^3.888.0", "@fastify/cors": "^11.2.0", "@fastify/jwt": "^10.2.2", "@fastify/type-provider-zod": "^1.0.0", diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 5aaf429..4c119d8 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -420,6 +420,22 @@ export const backupScheduleSettings = pgTable("backup_schedule_settings", { updatedAt: ts("updated_at").notNull().defaultNow(), }) +export const backupStorageSettings = pgTable("backup_storage_settings", { + id: idSingleton(), + provider: text("provider", { enum: ["local", "s3"] }).notNull().default("local"), + s3Endpoint: text("s3_endpoint").notNull().default(""), + s3Region: text("s3_region").notNull().default("us-east-1"), + s3Bucket: text("s3_bucket").notNull().default(""), + s3Prefix: text("s3_prefix").notNull().default("mikrotik"), + s3AccessKeyId: text("s3_access_key_id").notNull().default(""), + s3SecretAccessKey: text("s3_secret_access_key").notNull().default(""), + s3ForcePathStyle: boolean("s3_force_path_style").notNull().default(true), + keepLocalCopy: boolean("keep_local_copy").notNull().default(true), + lastTestAt: ts("last_test_at"), + lastTestError: text("last_test_error"), + updatedAt: ts("updated_at").notNull().defaultNow(), +}) + export const backupEntries = pgTable("backup_entries", { id: text("id").primaryKey(), serverId: bigint("server_id", { mode: "number" }) @@ -429,6 +445,10 @@ export const backupEntries = pgTable("backup_entries", { sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(), kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"), notes: text("notes"), + storage: text("storage", { enum: ["local", "s3", "both"] }).notNull().default("local"), + s3Key: text("s3_key"), + s3Etag: text("s3_etag"), + uploadError: text("upload_error"), createdAt: ts("created_at").notNull(), }, (t) => [ uniqueIndex("idx_backup_entries_filename").on(t.filename), diff --git a/backend/src/db/sqlite-import.ts b/backend/src/db/sqlite-import.ts index 78119c4..0c6e420 100644 --- a/backend/src/db/sqlite-import.ts +++ b/backend/src/db/sqlite-import.ts @@ -81,6 +81,12 @@ const TABLES: TableCopy[] = [ ["server_ids_json", "json"], ["last_run_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"], ["updated_at", "ts"], ]}, + { table: "backup_storage_settings", upsert: true, columns: [ + ["id", "int"], ["provider", "text"], ["s3_endpoint", "text"], ["s3_region", "text"], + ["s3_bucket", "text"], ["s3_prefix", "text"], ["s3_access_key_id", "text"], + ["s3_secret_access_key", "text"], ["s3_force_path_style", "bool"], ["keep_local_copy", "bool"], + ["last_test_at", "ts"], ["last_test_error", "text"], ["updated_at", "ts"], + ]}, { table: "internet_path_settings", upsert: true, columns: [ ["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"], ["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"], diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts index 786930b..c5c0c15 100644 --- a/backend/src/routes/backups.ts +++ b/backend/src/routes/backups.ts @@ -1,20 +1,26 @@ import { randomUUID } from "node:crypto" -import { readFile } from "node:fs/promises" -import path from "node:path" import { z } from "zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" -import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups" +import { + putBackupScheduleSettingsSchema, + putBackupStorageSettingsSchema, +} from "@mmapp/contracts/backups" import { listServersRead } from "../modules/servers/service/servers-service.js" import { appendEvent } from "../modules/events/service/events-service.js" import { refreshScheduler } from "../services/scheduler.js" import { deleteBackupRecord, getBackupById, - getBackupsDir, getBackupScheduleSettings, + getBackupStorageSettings, listBackups, + readBackupContent, + restoreBackupToDevice, runBackupForServer, + syncBackupsFromS3, + testBackupStorageConnection, updateBackupScheduleSettings, + updateBackupStorageSettings, type BackupMeta, } from "../services/backup-service.js" @@ -97,6 +103,39 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => { return reply.send(result) }) + app.get("/backups/storage", async (_req, reply) => { + return reply.send(await getBackupStorageSettings()) + }) + + app.put("/backups/storage", async (req, reply) => { + const parsed = putBackupStorageSettingsSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + return reply.send(await updateBackupStorageSettings(parsed.data)) + }) + + app.post("/backups/storage/test", async (_req, reply) => { + try { + return reply.send(await testBackupStorageConnection()) + } catch (err) { + return reply.status(400).send({ + error: err instanceof Error ? err.message : "Ошибка проверки S3", + settings: await getBackupStorageSettings(), + }) + } + }) + + app.post("/backups/storage/sync", async (_req, reply) => { + try { + return reply.send(await syncBackupsFromS3()) + } catch (err) { + return reply.status(400).send({ + error: err instanceof Error ? err.message : "Ошибка синхронизации S3", + }) + } + }) + app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => { const inputIds = req.body.serverIds.map((x) => String(x)) const notes = req.body.notes?.trim() || undefined @@ -169,12 +208,34 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => { app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => { const hit = await getBackupById(req.params.id) if (!hit) return reply.status(404).send({ error: "Бэкап не найден" }) - const filePath = path.join(getBackupsDir(), hit.filename) - const content = await readFile(filePath, "utf8").catch(() => null) - if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" }) - reply.header("Content-Type", "text/plain; charset=utf-8") - reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`) - return reply.send(content) + try { + const content = await readBackupContent(hit) + reply.header("Content-Type", "text/plain; charset=utf-8") + reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`) + return reply.send(content) + } catch { + return reply.status(404).send({ error: "Файл бэкапа не найден" }) + } + }) + + app.post("/backups/:id/restore", { schema: { params: BackupIdParamSchema } }, async (req, reply) => { + try { + const result = await restoreBackupToDevice(req.params.id) + await appendEvent({ + level: "warning", + eventType: "backups.restore", + sourceModule: "backups", + title: "Восстановление бэкапа", + message: `${result.filename} → ${result.serverName}`, + entityType: "backup", + entityId: req.params.id, + }) + return reply.send(result) + } catch (err) { + return reply.status(400).send({ + error: err instanceof Error ? err.message : "Не удалось восстановить бэкап", + }) + } }) app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => { diff --git a/backend/src/services/backup-service.ts b/backend/src/services/backup-service.ts index 59f1350..52ba82a 100644 --- a/backend/src/services/backup-service.ts +++ b/backend/src/services/backup-service.ts @@ -1,14 +1,30 @@ import { randomUUID } from "node:crypto" -import { mkdir, rm, stat, writeFile } from "node:fs/promises" +import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises" import path from "node:path" import { desc, eq } from "drizzle-orm" -import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups" +import type { + BackupScheduleSettingsDto, + BackupStorageSettingsDto, + PutBackupStorageSettings, +} from "@mmapp/contracts/backups" import { db } from "../db/index.js" import { parseJsonArray } from "../db/json.js" -import { backupEntries, backupScheduleSettings } from "../db/schema.js" +import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js" import { getServerRowById } from "../modules/servers/repository/servers-repository.js" import { listServersRead } from "../modules/servers/service/servers-service.js" import { MikrotikClient } from "./mikrotik.js" +import { + buildS3ObjectKey, + createS3ClientFromConfig, + parseS3ObjectKey, + sanitizeServerName, + s3DeleteObject, + s3GetObject, + s3ListObjects, + s3PutObject, + s3TestConnection, + type S3BackupConfig, +} from "./s3-backup-client.js" const SETTINGS_ID = 1 const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups") @@ -22,9 +38,14 @@ export type BackupMeta = { createdAt: string kind: "manual" | "auto" notes?: string + storage: "local" | "s3" | "both" + s3Key?: string | null + uploadError?: string | null } -function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta { +type BackupRow = typeof backupEntries.$inferSelect + +function rowToMeta(row: BackupRow): BackupMeta { return { id: row.id, serverId: row.serverId, @@ -34,6 +55,9 @@ function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta { createdAt: row.createdAt, kind: row.kind, notes: row.notes ?? undefined, + storage: row.storage ?? "local", + s3Key: row.s3Key, + uploadError: row.uploadError, } } @@ -60,16 +84,36 @@ export async function insertBackup(meta: BackupMeta): Promise { sizeBytes: meta.sizeBytes, kind: meta.kind, notes: meta.notes ?? null, + storage: meta.storage, + s3Key: meta.s3Key ?? null, + s3Etag: null, + uploadError: meta.uploadError ?? null, createdAt: meta.createdAt, }) } +async function getBackupRow(id: string): Promise { + return (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0] +} + export async function deleteBackupRecord(id: string): Promise { - const hit = await getBackupById(id) - if (!hit) return null + const row = await getBackupRow(id) + if (!row) return null + const meta = rowToMeta(row) + if (row.s3Key) { + try { + const cfg = await getS3ConfigIfEnabled() + if (cfg) { + const client = createS3ClientFromConfig(cfg) + await s3DeleteObject(client, cfg.bucket, row.s3Key) + } + } catch { + /* объект мог уже отсутствовать */ + } + } await db.delete(backupEntries).where(eq(backupEntries.id, id)) - await rm(path.join(BACKUPS_DIR, hit.filename), { force: true }) - return hit + await rm(path.join(BACKUPS_DIR, row.filename), { force: true }) + return meta } function fmtTs(d = new Date()): string { @@ -167,6 +211,127 @@ export async function touchBackupScheduleRunMeta(patch: { }).where(eq(backupScheduleSettings.id, SETTINGS_ID)) } +function defaultStorageRow() { + return { + id: SETTINGS_ID, + provider: "local" as const, + s3Endpoint: "", + s3Region: "us-east-1", + s3Bucket: "", + s3Prefix: "mikrotik", + s3AccessKeyId: "", + s3SecretAccessKey: "", + s3ForcePathStyle: true, + keepLocalCopy: true, + lastTestAt: null as string | null, + lastTestError: null as string | null, + updatedAt: new Date().toISOString(), + } +} + +async function getBackupStorageSettingsRow() { + return (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0] + ?? defaultStorageRow() +} + +function toStorageDto(row: Awaited>): BackupStorageSettingsDto { + return { + provider: row.provider, + s3Endpoint: row.s3Endpoint, + s3Region: row.s3Region, + s3Bucket: row.s3Bucket, + s3Prefix: row.s3Prefix, + s3AccessKeyId: row.s3AccessKeyId, + secretConfigured: Boolean(row.s3SecretAccessKey), + s3ForcePathStyle: row.s3ForcePathStyle, + keepLocalCopy: row.keepLocalCopy, + lastTestAt: row.lastTestAt ?? null, + lastTestError: row.lastTestError ?? null, + updatedAt: row.updatedAt, + } +} + +export async function getBackupStorageSettings(): Promise { + return toStorageDto(await getBackupStorageSettingsRow()) +} + +export async function updateBackupStorageSettings( + patch: PutBackupStorageSettings, +): 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, + } + 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 { + await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...next }) + } + return await getBackupStorageSettings() +} + +function rowToS3Config(row: Awaited>): S3BackupConfig | null { + if (row.provider !== "s3") return null + if (!row.s3Bucket.trim() || !row.s3AccessKeyId.trim() || !row.s3SecretAccessKey) return null + return { + endpoint: row.s3Endpoint, + region: row.s3Region, + bucket: row.s3Bucket.trim(), + prefix: row.s3Prefix, + accessKeyId: row.s3AccessKeyId, + secretAccessKey: row.s3SecretAccessKey, + forcePathStyle: row.s3ForcePathStyle, + } +} + +async function getS3ConfigIfEnabled(): Promise { + return rowToS3Config(await getBackupStorageSettingsRow()) +} + +export async function testBackupStorageConnection(): Promise { + const row = await getBackupStorageSettingsRow() + const cfg = rowToS3Config(row) + const now = new Date().toISOString() + if (!cfg) { + const error = row.provider === "s3" + ? "Заполните bucket, ключ доступа и секрет" + : "S3 не выбран" + await persistStorageTest(now, error) + 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() +} + +async function persistStorageTest(at: string, error: string | null) { + 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)) + } else { + await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...patch }) + } +} + export async function resolveBackupServerIds(settings: BackupScheduleSettingsDto): Promise { const enabled = new Set((await listServersRead()).map((s) => String(s.id))) const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled] @@ -214,6 +379,23 @@ export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, last return true } +async function uploadBackupToS3(params: { + serverName: string + filename: string + body: string +}): Promise<{ key: string; etag?: string } | { error: string }> { + const cfg = await getS3ConfigIfEnabled() + if (!cfg) return { error: "S3 не настроен" } + try { + const client = createS3ClientFromConfig(cfg) + const key = buildS3ObjectKey(cfg.prefix, sanitizeServerName(params.serverName), params.filename) + const put = await s3PutObject(client, cfg.bucket, key, params.body) + return { key, etag: put.etag } + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) } + } +} + export async function runBackupForServer( id: string, kind: BackupMeta["kind"], @@ -230,12 +412,33 @@ export async function runBackupForServer( const client = MikrotikClient.fromServer(row) const script = await client.exportConfigScript() const ts = fmtTs() - const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_") + const safeServer = sanitizeServerName(row.name) const filename = `${safeServer}_${ts}.rsc` const filePath = path.join(BACKUPS_DIR, filename) await ensureBackupStorage() await writeFile(filePath, script, "utf8") const st = await stat(filePath) + const storageRow = await getBackupStorageSettingsRow() + let storage: BackupMeta["storage"] = "local" + let s3Key: string | null = null + let s3Etag: string | null = null + let uploadError: string | null = null + + if (storageRow.provider === "s3") { + const uploaded = await uploadBackupToS3({ serverName: row.name, filename, body: script }) + if ("key" in uploaded) { + s3Key = uploaded.key + s3Etag = uploaded.etag ?? null + storage = storageRow.keepLocalCopy ? "both" : "s3" + if (!storageRow.keepLocalCopy) { + await rm(filePath, { force: true }) + } + } else { + uploadError = uploaded.error + storage = "local" + } + } + const meta: BackupMeta = { id: randomUUID(), serverId: row.id, @@ -245,8 +448,24 @@ export async function runBackupForServer( createdAt: new Date().toISOString(), kind, notes, + storage, + s3Key, + uploadError, } - await insertBackup(meta) + await db.insert(backupEntries).values({ + id: meta.id, + serverId: meta.serverId, + serverName: meta.serverName, + filename: meta.filename, + sizeBytes: meta.sizeBytes, + kind: meta.kind, + notes: meta.notes ?? null, + storage: meta.storage, + s3Key: meta.s3Key ?? null, + s3Etag, + uploadError: meta.uploadError ?? null, + createdAt: meta.createdAt, + }) return meta } @@ -257,12 +476,82 @@ export async function pruneBackupsForServer(serverId: string, keepCount: number) if (rows.length <= keepCount) return 0 const toDelete = rows.slice(keepCount) for (const hit of toDelete) { - await db.delete(backupEntries).where(eq(backupEntries.id, hit.id)) - await rm(path.join(BACKUPS_DIR, hit.filename), { force: true }) + await deleteBackupRecord(hit.id) } return toDelete.length } +export async function readBackupContent(meta: BackupMeta): Promise { + if ((meta.storage === "s3" || meta.storage === "both") && meta.s3Key) { + try { + const cfg = await getS3ConfigIfEnabled() + if (cfg) { + const client = createS3ClientFromConfig(cfg) + return await s3GetObject(client, cfg.bucket, meta.s3Key) + } + } catch { + /* fallback: локальная копия, если есть */ + } + } + return await readFile(path.join(BACKUPS_DIR, meta.filename)) +} + +export async function restoreBackupToDevice(id: string): Promise<{ filename: string; serverName: string }> { + const meta = await getBackupById(id) + if (!meta) throw new Error("Бэкап не найден") + if (!meta.serverId) throw new Error("Сервер бэкапа удалён — восстановить нельзя") + const row = await getServerRowById(meta.serverId) + if (!row) throw new Error("Сервер не найден") + const content = await readBackupContent(meta) + const client = MikrotikClient.fromServer(row) + const uploaded = await client.uploadTextFile(meta.filename, content.toString("utf8"), 60_000) + await client.importUploadedFile(uploaded) + return { filename: meta.filename, serverName: row.name } +} + +export async function syncBackupsFromS3(): Promise<{ imported: number; skipped: number }> { + const cfg = await getS3ConfigIfEnabled() + if (!cfg) throw new Error("S3 не настроен") + const client = createS3ClientFromConfig(cfg) + const objects = await s3ListObjects(client, cfg.bucket, cfg.prefix) + const existing = new Set( + (await db.select({ filename: backupEntries.filename, s3Key: backupEntries.s3Key }).from(backupEntries)) + .flatMap((row) => [row.filename, row.s3Key].filter((v): v is string => Boolean(v))), + ) + let imported = 0 + let skipped = 0 + for (const obj of objects) { + if (!obj.key.toLowerCase().endsWith(".rsc")) { + skipped += 1 + continue + } + const parsed = parseS3ObjectKey(obj.key) + if (existing.has(obj.key) || existing.has(parsed.filename)) { + skipped += 1 + continue + } + const createdAt = obj.lastModified ?? new Date().toISOString() + await db.insert(backupEntries).values({ + id: randomUUID(), + serverId: null, + serverName: parsed.serverName, + filename: parsed.filename, + sizeBytes: obj.size, + kind: "auto", + notes: "Импорт из S3", + storage: "s3", + s3Key: obj.key, + s3Etag: null, + uploadError: null, + createdAt, + }) + existing.add(obj.key) + existing.add(parsed.filename) + imported += 1 + } + return { imported, skipped } +} + export function getBackupsDir(): string { return BACKUPS_DIR } diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index d063cce..271ef9c 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -586,6 +586,14 @@ export class MikrotikClient { : new Error(`Не удалось загрузить файл ${normalized} на RouterOS`) } + async importUploadedFile(fileName: string): Promise { + try { + return await this.post("/import", { "file-name": fileName }, 120_000) + } catch { + return await this.post("/execute", { script: `/import file-name="${fileName}"` }, 120_000) + } + } + async importCertificate(params: { fileName: string name: string diff --git a/backend/src/services/s3-backup-client.test.ts b/backend/src/services/s3-backup-client.test.ts new file mode 100644 index 0000000..4f9fd31 --- /dev/null +++ b/backend/src/services/s3-backup-client.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict" +import { + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + ListObjectsV2Command, + PutObjectCommand, + type S3Client, +} from "@aws-sdk/client-s3" +import { + buildS3ObjectKey, + normalizeS3Prefix, + parseS3ObjectKey, + sanitizeServerName, + s3DeleteObject, + s3GetObject, + s3ListObjects, + s3PutObject, + s3TestConnection, +} from "./s3-backup-client.js" + +assert.equal(normalizeS3Prefix("/mikrotik/backups/"), "mikrotik/backups") +assert.equal(sanitizeServerName("MSK CHR 01"), "MSK_CHR_01") +assert.equal( + buildS3ObjectKey("mikrotik", "msk-chr01", "chr_2026-09-08_03-00-00.rsc"), + "mikrotik/msk-chr01/chr_2026-09-08_03-00-00.rsc", +) +assert.deepEqual( + parseS3ObjectKey("mikrotik/msk-chr01/chr_2026-09-08_03-00-00.rsc"), + { filename: "chr_2026-09-08_03-00-00.rsc", serverName: "msk-chr01" }, +) + +const store = new Map() +let lastCommand = "" + +const fake = { + send: async (command: { input?: Record }) => { + const name = command.constructor.name + lastCommand = name + const input = command.input ?? {} + if (command instanceof HeadBucketCommand || name === "HeadBucketCommand") { + if (input.Bucket !== "backups") throw new Error("no bucket") + return {} + } + if (command instanceof PutObjectCommand || name === "PutObjectCommand") { + const key = String(input.Key) + const body = input.Body + store.set(key, Buffer.isBuffer(body) ? body : Buffer.from(String(body))) + return { ETag: '"etag-1"' } + } + if (command instanceof GetObjectCommand || name === "GetObjectCommand") { + const key = String(input.Key) + const body = store.get(key) + if (!body) throw new Error("not found") + return { Body: { transformToByteArray: async () => new Uint8Array(body) } } + } + if (command instanceof DeleteObjectCommand || name === "DeleteObjectCommand") { + store.delete(String(input.Key)) + return {} + } + if (command instanceof ListObjectsV2Command || name === "ListObjectsV2Command") { + const prefix = String(input.Prefix ?? "") + const contents = [...store.entries()] + .filter(([key]) => !prefix || key.startsWith(prefix)) + .map(([key, buf]) => ({ Key: key, Size: buf.length, LastModified: new Date("2026-09-08T00:00:00Z") })) + return { Contents: contents, IsTruncated: false } + } + throw new Error(`unexpected command ${name}`) + }, +} as unknown as S3Client + +await s3TestConnection(fake, "backups") +assert.equal(lastCommand === "HeadBucketCommand" || lastCommand.includes("Head"), true) + +const put = await s3PutObject(fake, "backups", "mikrotik/a/file.rsc", "hello") +assert.equal(put.etag, '"etag-1"') + +const got = await s3GetObject(fake, "backups", "mikrotik/a/file.rsc") +assert.equal(got.toString("utf8"), "hello") + +const listed = await s3ListObjects(fake, "backups", "mikrotik") +assert.equal(listed.length, 1) +assert.equal(listed[0]?.key, "mikrotik/a/file.rsc") + +await s3DeleteObject(fake, "backups", "mikrotik/a/file.rsc") +const after = await s3ListObjects(fake, "backups", "mikrotik") +assert.equal(after.length, 0) + +console.log("s3-backup-client.test.ts: ok") diff --git a/backend/src/services/s3-backup-client.ts b/backend/src/services/s3-backup-client.ts new file mode 100644 index 0000000..b5a3eb7 --- /dev/null +++ b/backend/src/services/s3-backup-client.ts @@ -0,0 +1,128 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, + type S3ClientConfig, +} from "@aws-sdk/client-s3" + +export type S3BackupConfig = { + endpoint: string + region: string + bucket: string + prefix: string + accessKeyId: string + secretAccessKey: string + forcePathStyle: boolean +} + +export type S3ListedObject = { + key: string + size: number + lastModified?: string +} + +export function normalizeS3Prefix(prefix: string): string { + return prefix.trim().replace(/^\/+|\/+$/g, "") +} + +export function sanitizeServerName(name: string): string { + const safe = name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") + return safe || "server" +} + +export function buildS3ObjectKey(prefix: string, serverSafe: string, filename: string): string { + const parts = [normalizeS3Prefix(prefix), sanitizeServerName(serverSafe), filename] + .filter((part) => part.length > 0) + return parts.join("/") +} + +export function parseS3ObjectKey(key: string): { filename: string; serverName: string } { + const parts = key.split("/").filter(Boolean) + const filename = parts.pop() ?? key + const folder = parts.pop() ?? "" + const base = filename.replace(/\.(rsc|backup)$/i, "") + const fromFilename = base.replace(/_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/, "") + return { filename, serverName: folder || fromFilename || filename } +} + +export function createS3ClientFromConfig(cfg: S3BackupConfig): S3Client { + const options: S3ClientConfig = { + region: cfg.region.trim() || "us-east-1", + credentials: { + accessKeyId: cfg.accessKeyId, + secretAccessKey: cfg.secretAccessKey, + }, + forcePathStyle: cfg.forcePathStyle, + } + const endpoint = cfg.endpoint.trim() + if (endpoint) options.endpoint = endpoint + return new S3Client(options) +} + +export async function s3TestConnection(client: S3Client, bucket: string): Promise { + try { + await client.send(new HeadBucketCommand({ Bucket: bucket })) + } catch { + await client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 1 })) + } +} + +export async function s3PutObject( + client: S3Client, + bucket: string, + key: string, + body: Buffer | string, +): Promise<{ etag?: string }> { + const out = await client.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: "text/plain; charset=utf-8", + })) + return { etag: out.ETag } +} + +export async function s3GetObject( + client: S3Client, + bucket: string, + key: string, +): Promise { + const out = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key })) + const bytes = await out.Body?.transformToByteArray() + if (!bytes) throw new Error("Пустой объект S3") + return Buffer.from(bytes) +} + +export async function s3DeleteObject(client: S3Client, bucket: string, key: string): Promise { + await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })) +} + +export async function s3ListObjects( + client: S3Client, + bucket: string, + prefix: string, +): Promise { + const items: S3ListedObject[] = [] + let token: string | undefined + const normalized = normalizeS3Prefix(prefix) + do { + const out = await client.send(new ListObjectsV2Command({ + Bucket: bucket, + Prefix: normalized ? `${normalized}/` : undefined, + ContinuationToken: token, + })) + for (const obj of out.Contents ?? []) { + if (!obj.Key) continue + items.push({ + key: obj.Key, + size: obj.Size ?? 0, + lastModified: obj.LastModified?.toISOString(), + }) + } + token = out.IsTruncated ? out.NextContinuationToken : undefined + } while (token) + return items +} diff --git a/components/backups/backup-confirm-dialogs.tsx b/components/backups/backup-confirm-dialogs.tsx new file mode 100644 index 0000000..6177778 --- /dev/null +++ b/components/backups/backup-confirm-dialogs.tsx @@ -0,0 +1,97 @@ +"use client" + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert" +import type { Backup } from "@/lib/data" +import { AlertCircleIcon, LoaderCircleIcon, TriangleAlertIcon } from "lucide-react" + +export function BackupDeleteDialog({ + backup, + busy, + onConfirm, + onCancel, +}: { + backup: Backup | null + busy: boolean + onConfirm: () => void + onCancel: () => void +}) { + return ( + { if (!v && !busy) onCancel() }}> + + + + + + Удалить бэкап? + + Файл {backup?.filename} будет удалён + {backup?.storage === "s3" || backup?.storage === "both" ? " локально и из S3" : " с диска"}. + + + + Отмена + + {busy ? : null} + Удалить + + + + + ) +} + +export function BackupRestoreDialog({ + backup, + busy, + onConfirm, + onCancel, +}: { + backup: Backup | null + busy: boolean + onConfirm: () => void + onCancel: () => void +}) { + return ( + { if (!v && !busy) onCancel() }}> + + + + + + Восстановить конфигурацию? + + + Файл {backup?.filename} будет загружен + на {backup?.server} и импортирован. + + + + Это изменит рабочую конфигурацию роутера + + Сессия может оборваться. Убедитесь, что выбран именно этот сервер и этот снимок. + + + + + + Отмена + + {busy ? : null} + Восстановить + + + + + ) +} diff --git a/components/backups/backup-create-sheet.tsx b/components/backups/backup-create-sheet.tsx new file mode 100644 index 0000000..a42cf53 --- /dev/null +++ b/components/backups/backup-create-sheet.tsx @@ -0,0 +1,199 @@ +"use client" + +import type { Server } from "@/lib/data" +import { FormField } from "@/components/form-kit" +import { StatusBadge } from "@/components/status-badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { + Stepper, + StepperContent, + StepperIndicator, + StepperItem, + StepperNav, + StepperPanel, + StepperSeparator, + StepperTitle, + StepperTrigger, +} from "@/components/reui/stepper" +import { cn } from "@/lib/utils" + +export function BackupCreateSheet({ + open, + onOpenChange, + step, + onStepChange, + servers, + selected, + onToggle, + onSelectAll, + onClear, + notes, + onNotesChange, + destinationLabel, + busy, + onSubmit, +}: { + open: boolean + onOpenChange: (open: boolean) => void + step: number + onStepChange: (step: number) => void + servers: Server[] + selected: Set + onToggle: (id: string) => void + onSelectAll: () => void + onClear: () => void + notes: string + onNotesChange: (value: string) => void + destinationLabel: string + busy: boolean + onSubmit: () => void +}) { + return ( + { onOpenChange(v); if (!v) onStepChange(1) }}> + + + Новый бэкап + Снять конфигурацию вручную с выбранных серверов + + + + + + + 1 + Серверы + + + + + + 2 + Заметка + + + + + + 3 + Подтверждение + + + + + +
+

Выберите серверы

+
+ + · + +
+
+ {servers.map((s) => { + const checked = selected.has(s.id) + return ( + + ) + })} +
+ + + onNotesChange(e.target.value)} + /> + + + +

+ Будет создан бэкап для {selected.size} серверов. +

+

+ Куда сохранится: {destinationLabel} +

+ {notes ?

Заметка: {notes}

: null} +
+
+
+ + + }>Отмена + {step > 1 && ( + + )} + {step < 3 ? ( + + ) : ( + + )} + +
+
+ ) +} diff --git a/components/backups/backups-history.tsx b/components/backups/backups-history.tsx new file mode 100644 index 0000000..fce98be --- /dev/null +++ b/components/backups/backups-history.tsx @@ -0,0 +1,97 @@ +"use client" + +import type { Filter } from "@/components/reui/filters" +import type { Backup } from "@/lib/data" +import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters" +import { + BACKUP_FILTER_ACCESSORS, + BACKUP_FILTER_FIELDS, +} from "@/lib/data-filters/backup-filter-fields" +import { DataPageCard } from "@/components/data-page-card" +import { DataPageToolbar } from "@/components/data-page-toolbar" +import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid" +import { Button } from "@/components/ui/button" +import { PlusIcon } from "lucide-react" + +type KindFilter = "all" | "auto" | "manual" + +export function BackupsHistory({ + backups, + kindFilter, + onKindFilterChange, + search, + onSearchChange, + filters, + onFiltersChange, + onDownload, + onRestore, + onDelete, + onCreate, +}: { + backups: Backup[] + kindFilter: KindFilter + onKindFilterChange: (value: KindFilter) => void + search: string + onSearchChange: (value: string) => void + filters: Filter[] + onFiltersChange: (filters: Filter[]) => void + onDownload: (id: string, filename: string) => void + onRestore: (backup: Backup) => void + onDelete: (backup: Backup) => void + onCreate: () => void +}) { + const autoCount = backups.filter((b) => b.kind === "auto").length + const manualCount = backups.filter((b) => b.kind === "manual").length + const byKind = kindFilter === "all" ? backups : backups.filter((b) => b.kind === kindFilter) + const q = search.trim().toLowerCase() + const searched = q + ? byKind.filter((b) => + [b.filename, b.server, b.notes].some((v) => v.toLowerCase().includes(q)), + ) + : byKind + const filtered = applyReuiFilters(searched, filters, BACKUP_FILTER_ACCESSORS) + const isEmptyAll = backups.length === 0 + + return ( + + + + + Новый бэкап + + ) : null + } + /> + + ) +} diff --git a/components/backups/backups-settings.tsx b/components/backups/backups-settings.tsx new file mode 100644 index 0000000..6fa0d21 --- /dev/null +++ b/components/backups/backups-settings.tsx @@ -0,0 +1,427 @@ +"use client" + +import type { Server } from "@/lib/data" +import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups" +import { Badge } from "@/components/reui/badge" +import { OpsPanel } from "@/components/ops-panel" +import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit" +import { StatusBadge } from "@/components/status-badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" +import { LoaderCircleIcon } from "lucide-react" + +export const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] + +export type BackupFreq = "daily" | "weekly" | "monthly" +export type StorageProvider = "local" | "s3" + +export type BackupScheduleForm = { + enabled: boolean + frequency: BackupFreq + hour: number + minute: number + weekDay: number + monthDay: number + keepCount: number + format: "rsc" | "backup" +} + +export type BackupStorageForm = { + provider: StorageProvider + s3Endpoint: string + s3Region: string + s3Bucket: string + s3Prefix: string + s3AccessKeyId: string + s3SecretAccessKey: string + s3ForcePathStyle: boolean + keepLocalCopy: boolean + showPassword: boolean +} + +export const defaultSchedule: BackupScheduleForm = { + enabled: true, + frequency: "daily", + hour: 3, + minute: 0, + weekDay: 0, + monthDay: 1, + keepCount: 7, + format: "rsc", +} + +export const defaultStorageForm: BackupStorageForm = { + provider: "local", + s3Endpoint: "", + s3Region: "us-east-1", + s3Bucket: "", + s3Prefix: "mikrotik", + s3AccessKeyId: "", + s3SecretAccessKey: "", + s3ForcePathStyle: true, + keepLocalCopy: true, + showPassword: false, +} + +function storageStatus(saved: BackupStorageSettingsDto | null, form: BackupStorageForm) { + if (form.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 } + } + if (saved?.secretConfigured && saved.s3Bucket) { + return { label: "Не проверено", variant: "warning-light" as const } + } + return { label: "Не настроено", variant: "secondary" as const } +} + +export function BackupsSettings({ + schedule, + onScheduleChange, + storage, + onStorageChange, + savedStorage, + servers, + selectedServers, + onToggleServer, + onSelectAll, + onClearServers, + onSave, + onTest, + onSync, + saveBusy, + testBusy, + syncBusy, +}: { + schedule: BackupScheduleForm + onScheduleChange: (k: K, v: BackupScheduleForm[K]) => void + storage: BackupStorageForm + onStorageChange: (k: K, v: BackupStorageForm[K]) => void + savedStorage: BackupStorageSettingsDto | null + servers: Server[] + selectedServers: Set + onToggleServer: (id: string) => void + onSelectAll: () => void + onClearServers: () => void + onSave: () => void + onTest: () => void + onSync: () => void + saveBusy: boolean + testBusy: boolean + syncBusy: boolean +}) { + const status = storageStatus(savedStorage, storage) + const secretPlaceholder = savedStorage?.secretConfigured ? "•••••••• (сохранён)" : "••••••••" + + return ( +
+ +
+
+

Автоматический бэкап

+

Создавать бэкапы по расписанию

+
+ onScheduleChange("enabled", v)} /> +
+ +
+ + onScheduleChange("frequency", v)} + options={[ + { value: "daily", label: "Ежедневно" }, + { value: "weekly", label: "Еженедельно" }, + { value: "monthly", label: "Ежемесячно" }, + ]} + /> + + + {schedule.frequency === "weekly" && ( + +
+ {WEEK_DAYS.map((d, i) => ( + + ))} +
+
+ )} + + {schedule.frequency === "monthly" && ( + + onScheduleChange("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} + /> + + )} + + +
+ onScheduleChange("hour", Math.min(23, Math.max(0, Number(e.target.value))))} + /> + : +
+ {[0, 15, 30, 45].map((m) => ( + + ))} +
+
+
+ + + onScheduleChange("keepCount", Math.max(1, Number(e.target.value)))} + /> + + +
+
+

Формат файла

+

Снимается текстовый экспорт RouterOS

+
+
+ .rsc + .backup скоро +
+
+
+
+ + + {status.label} + + } + contentClassName="px-5 py-5 flex flex-col gap-5" + > + + onStorageChange("provider", v)} + options={[ + { value: "local", label: "Локально" }, + { value: "s3", label: "S3" }, + ]} + /> + + + {storage.provider === "local" ? ( +

+ Файлы пишутся в каталог приложения storage/backups. +

+ ) : ( +
+ + onStorageChange("s3Endpoint", e.target.value)} + /> + +
+ + onStorageChange("s3Region", e.target.value)} + /> + + + onStorageChange("s3Bucket", e.target.value)} + /> + +
+ + onStorageChange("s3Prefix", e.target.value)} + /> + +
+ + onStorageChange("s3AccessKeyId", e.target.value)} + /> + + +
+ onStorageChange("s3SecretAccessKey", e.target.value)} + /> + +
+
+
+
+
+

Path-style

+

Нужен для MinIO и части совместимых API

+
+ onStorageChange("s3ForcePathStyle", v)} /> +
+
+
+

Оставлять локальную копию

+

После успешной загрузки в S3

+
+ onStorageChange("keepLocalCopy", v)} /> +
+ {savedStorage?.lastTestError ? ( +

{savedStorage.lastTestError}

+ ) : null} +
+ + +
+
+ )} +
+ + + + · + +
+ } + contentClassName="px-5 py-5 flex flex-col gap-4" + > +
+ {servers.map((s) => { + const checked = selectedServers.has(s.id) + return ( + + ) + })} +
+

+ Выбрано {selectedServers.size} из {servers.length} серверов +

+ + +
+ +
+ + ) +} diff --git a/components/data-grids/backups-data-grid.tsx b/components/data-grids/backups-data-grid.tsx index 25c473f..cebb5d1 100644 --- a/components/data-grids/backups-data-grid.tsx +++ b/components/data-grids/backups-data-grid.tsx @@ -1,6 +1,6 @@ "use client" -import { useMemo } from "react" +import { useMemo, type ReactNode } from "react" import { type ColumnDef, getCoreRowModel, @@ -8,8 +8,9 @@ import { useReactTable, } from "@tanstack/react-table" import type { Backup } from "@/lib/data" -import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" +import { Badge } from "@/components/reui/badge" +import { IconTile } from "@/components/reui/icon-tile" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" import { DATA_GRID_CELL_PAD, @@ -18,13 +19,28 @@ import { } from "@/components/data-grids/shared/data-grid-layout" import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" import { EmptyState } from "@/components/empty-state" -import { DownloadIcon, HardDriveIcon, RefreshCwIcon, Trash2Icon } from "lucide-react" +import { + CloudIcon, + DownloadIcon, + HardDriveIcon, + RefreshCwIcon, + Trash2Icon, +} from "lucide-react" interface BackupsDataGridProps { backups: Backup[] onDownload: (id: string, filename: string) => void onRestore: (backup: Backup) => void - onDelete: (id: string) => void + onDelete: (backup: Backup) => void + emptyAction?: ReactNode + emptyTitle?: string + emptyDescription?: string +} + +function storageBadge(storage: Backup["storage"]) { + if (storage === "s3") return { label: "S3", variant: "info-light" as const } + if (storage === "both") return { label: "Локально + S3", variant: "success-light" as const } + return { label: "Локально", variant: "secondary" as const } } function BackupsDataGrid({ @@ -32,6 +48,9 @@ function BackupsDataGrid({ onDownload, onRestore, onDelete, + emptyAction, + emptyTitle = "Нет бэкапов", + emptyDescription = "Создайте первый бэкап вручную или настройте расписание", }: BackupsDataGridProps) { const columns = useMemo[]>( () => [ @@ -39,9 +58,27 @@ function BackupsDataGrid({ id: "filename", accessorKey: "filename", header: ({ column }) => , - cell: ({ row }) => ( - {row.original.filename} - ), + cell: ({ row }) => { + const b = row.original + const inS3 = b.storage === "s3" || b.storage === "both" + return ( +
+ +
+ {b.filename} + {b.uploadError ? ( + {b.uploadError} + ) : null} +
+
+ ) + }, meta: { headerTitle: "Файл", headerClassName: DATA_GRID_CELL_PAD_FIRST, @@ -78,23 +115,35 @@ function BackupsDataGrid({ id: "kind", accessorKey: "kind", header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.kind === "auto" ? "авто" : "вручную"} + + ), + meta: { + headerTitle: "Тип", + headerClassName: DATA_GRID_CELL_PAD, + cellClassName: DATA_GRID_CELL_PAD, + }, + }, + { + id: "storage", + accessorKey: "storage", + header: ({ column }) => , cell: ({ row }) => { - const kind = row.original.kind + const badge = storageBadge(row.original.storage) return ( - - {kind === "auto" ? "авто" : "вручную"} - + + {badge.label} + ) }, meta: { - headerTitle: "Тип", + headerTitle: "Хранилище", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD, }, @@ -135,29 +184,35 @@ function BackupsDataGrid({ return (
@@ -186,9 +241,10 @@ function BackupsDataGrid({ if (backups.length === 0) { return ( } - title="Нет бэкапов" - description="Создайте первый бэкап вручную или настройте расписание" + icon={} + title={emptyTitle} + description={emptyDescription} + action={emptyAction} className="border-0 py-10" /> ) diff --git a/components/data-page-toolbar.tsx b/components/data-page-toolbar.tsx index c36c662..e1d47ed 100644 --- a/components/data-page-toolbar.tsx +++ b/components/data-page-toolbar.tsx @@ -1,13 +1,14 @@ "use client" import { ReactNode } from "react" -import { SearchIcon } from "lucide-react" +import { ListFilterIcon, SearchIcon } from "lucide-react" import { cn } from "@/lib/utils" import { InputGroup, InputGroupAddon, InputGroupInput, } from "@/components/ui/input-group" +import { Button } from "@/components/ui/button" import { Filters, type Filter, @@ -77,6 +78,12 @@ function DataPageToolbar({ fields={filterFields} onChange={onFiltersChange} size="sm" + trigger={ + + } /> )} {onSearchChange != null && ( diff --git a/lib/data-filters/backup-filter-fields.ts b/lib/data-filters/backup-filter-fields.ts new file mode 100644 index 0000000..ad1b182 --- /dev/null +++ b/lib/data-filters/backup-filter-fields.ts @@ -0,0 +1,36 @@ +import type { FilterFieldConfig } from "@/components/reui/filters" +import type { Backup } from "@/lib/data" + +export const BACKUP_FILTER_FIELDS: FilterFieldConfig[] = [ + { + key: "server", + label: "Сервер", + type: "text", + placeholder: "Имя сервера", + }, + { + key: "kind", + label: "Тип", + type: "multiselect", + options: [ + { value: "auto", label: "авто" }, + { value: "manual", label: "вручную" }, + ], + }, + { + key: "storage", + label: "Хранилище", + type: "multiselect", + options: [ + { value: "local", label: "Локально" }, + { value: "s3", label: "S3" }, + { value: "both", label: "Локально + S3" }, + ], + }, +] + +export const BACKUP_FILTER_ACCESSORS = { + server: (b: Backup) => b.server, + kind: (b: Backup) => b.kind, + storage: (b: Backup) => b.storage, +} diff --git a/lib/data.ts b/lib/data.ts index 7dc17e8..5d1bff3 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -189,11 +189,14 @@ export interface FirewallAddressListEntry { export interface Backup { id: string server: string + serverId?: string | null filename: string size: string created: string kind: "auto" | "manual" notes: string + storage: "local" | "s3" | "both" + uploadError?: string } // ─── VXLAN ─────────────────────────────────────────────────────────────────── @@ -467,11 +470,11 @@ export const firewallRules: FirewallRule[] = [ ] export const backups: Backup[] = [ - { id: "b1", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-26_03-00.rsc", size: "124 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "снапшот перед обновлением" }, - { id: "b2", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-25_03-00.rsc", size: "124 КБ", created: "Вчера, 03:00", kind: "auto", notes: "" }, - { id: "b3", server: "mt-spb-edge-01", filename: "mt-spb-edge-01_2026-04-26_03-00.rsc", size: "88 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "" }, - { id: "b4", server: "mt-fra-edge-01", filename: "mt-fra-edge-01_2026-04-26_manual.rsc",size: "92 КБ", created: "Сегодня, 14:18", kind: "manual", notes: "перед изменением BGP" }, - { id: "b5", server: "mt-ams-edge-01", filename: "mt-ams-edge-01_2026-04-25_03-00.rsc", size: "64 КБ", created: "Вчера, 03:00", kind: "auto", notes: "" }, + { id: "b1", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-26_03-00.rsc", size: "124 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "снапшот перед обновлением", storage: "local" }, + { id: "b2", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-25_03-00.rsc", size: "124 КБ", created: "Вчера, 03:00", kind: "auto", notes: "", storage: "local" }, + { id: "b3", server: "mt-spb-edge-01", filename: "mt-spb-edge-01_2026-04-26_03-00.rsc", size: "88 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "", storage: "s3" }, + { id: "b4", server: "mt-fra-edge-01", filename: "mt-fra-edge-01_2026-04-26_manual.rsc",size: "92 КБ", created: "Сегодня, 14:18", kind: "manual", notes: "перед изменением BGP", storage: "both" }, + { id: "b5", server: "mt-ams-edge-01", filename: "mt-ams-edge-01_2026-04-25_03-00.rsc", size: "64 КБ", created: "Вчера, 03:00", kind: "auto", notes: "", storage: "local" }, ] export const pingProbes: PingProbe[] = [ diff --git a/package-lock.json b/package-lock.json index 1b1b8c4..4b3b970 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "name": "mikrotik-manager-backend", "version": "1.0.0", "dependencies": { + "@aws-sdk/client-s3": "^3.888.0", "@fastify/cors": "^11.2.0", "@fastify/jwt": "^10.2.2", "@fastify/type-provider-zod": "^1.0.0", @@ -110,6 +111,314 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.29.tgz", + "integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1127.0.tgz", + "integrity": "sha512-0ZSAgmEda33xPqVPt+bx2KzrXV1cUCKRRVPGliLu+V7DzHPa04CqPSi1maGEM4O0LDP0iI6HRSW5ULloNwayNw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.29", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/middleware-sdk-s3": "^3.972.75", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz", + "integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -3759,6 +4068,87 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5565,6 +5955,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -14401,6 +14797,21 @@ "dependencies": { "zod": "^4.4.1" } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/packages/contracts/src/backups.ts b/packages/contracts/src/backups.ts index 36b3eef..fc5edf9 100644 --- a/packages/contracts/src/backups.ts +++ b/packages/contracts/src/backups.ts @@ -2,6 +2,8 @@ import { z } from "zod" export const backupFrequencySchema = z.enum(["daily", "weekly", "monthly"]) export const backupFormatSchema = z.enum(["rsc", "backup"]) +export const backupStorageProviderSchema = z.enum(["local", "s3"]) +export const backupObjectStorageSchema = z.enum(["local", "s3", "both"]) export const backupScheduleSettingsDtoSchema = z.object({ enabled: z.boolean(), @@ -32,3 +34,48 @@ export const putBackupScheduleSettingsSchema = z.object({ }) export type BackupScheduleSettingsDto = z.infer + +export const backupStorageSettingsDtoSchema = z.object({ + provider: backupStorageProviderSchema, + s3Endpoint: z.string(), + s3Region: z.string(), + s3Bucket: z.string(), + s3Prefix: z.string(), + s3AccessKeyId: z.string(), + secretConfigured: z.boolean(), + s3ForcePathStyle: z.boolean(), + keepLocalCopy: z.boolean(), + lastTestAt: z.string().nullable().optional(), + lastTestError: z.string().nullable().optional(), + updatedAt: z.string().optional(), +}) + +export const putBackupStorageSettingsSchema = z.object({ + provider: backupStorageProviderSchema.optional(), + s3Endpoint: z.string().optional(), + s3Region: z.string().optional(), + s3Bucket: z.string().optional(), + s3Prefix: z.string().optional(), + s3AccessKeyId: z.string().optional(), + s3SecretAccessKey: z.string().optional(), + s3ForcePathStyle: z.boolean().optional(), + keepLocalCopy: z.boolean().optional(), +}) + +export const backupItemDtoSchema = z.object({ + id: z.string(), + serverId: z.number().nullable(), + serverName: z.string(), + filename: z.string(), + sizeBytes: z.number(), + createdAt: z.string(), + kind: z.enum(["manual", "auto"]), + notes: z.string().optional(), + storage: backupObjectStorageSchema, + s3Key: z.string().nullable().optional(), + uploadError: z.string().nullable().optional(), +}) + +export type BackupStorageSettingsDto = z.infer +export type PutBackupStorageSettings = z.infer +export type BackupItemDto = z.infer diff --git a/shared/api/backups.ts b/shared/api/backups.ts index 29c62f9..16b317e 100644 --- a/shared/api/backups.ts +++ b/shared/api/backups.ts @@ -1,15 +1,22 @@ import { requestJson } from "@/shared/api/http-client" -import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups" +import type { + BackupScheduleSettingsDto, + BackupStorageSettingsDto, + PutBackupStorageSettings, +} from "@mmapp/contracts/backups" export type BackupItem = { id: string - serverId: string + serverId: number | string | null serverName: string filename: string sizeBytes: number createdAt: string kind: "manual" | "auto" notes?: string + storage?: "local" | "s3" | "both" + s3Key?: string | null + uploadError?: string | null } type CreateBackupResponse = { @@ -68,6 +75,12 @@ export async function deleteBackup(baseUrl: string, id: string): Promise { await requestJson(baseUrl, `/api/backups/${id}`, { method: "DELETE" }) } +export async function restoreBackup(baseUrl: string, id: string): Promise<{ filename: string; serverName: string }> { + return requestJson<{ filename: string; serverName: string }>(baseUrl, `/api/backups/${id}/restore`, { + method: "POST", + }) +} + export async function getBackupScheduleSettings(baseUrl: string): Promise { return requestJson(baseUrl, "/api/backups/schedule") } @@ -81,3 +94,29 @@ export async function putBackupScheduleSettings( body: JSON.stringify(payload), }) } + +export async function getBackupStorageSettings(baseUrl: string): Promise { + return requestJson(baseUrl, "/api/backups/storage") +} + +export async function putBackupStorageSettings( + baseUrl: string, + payload: PutBackupStorageSettings, +): Promise { + return requestJson(baseUrl, "/api/backups/storage", { + method: "PUT", + body: JSON.stringify(payload), + }) +} + +export async function testBackupStorage(baseUrl: string): Promise { + return requestJson(baseUrl, "/api/backups/storage/test", { method: "POST" }) +} + +export async function syncBackupsFromStorage( + baseUrl: string, +): Promise<{ imported: number; skipped: number }> { + return requestJson<{ imported: number; skipped: number }>(baseUrl, "/api/backups/storage/sync", { + method: "POST", + }) +}