fileInput?.click()} disabled={backupsUploading}>
{backupsUploading
@@ -222,11 +238,18 @@
aria-label={archive.name}
/>
- |
+ |
{archive.name}
|
- {fmtDate(archiveDate(archive))} |
- {formatSize(archive.size_bytes)} |
+ {fmtDate(archiveDate(archive))} |
+ {formatSize(archive.size_bytes)} |
{#if archive.has_database}
diff --git a/frontend/src/lib/admin/stores/backupsStore.js b/frontend/src/lib/admin/stores/backupsStore.js
index f1e9035..43a3cf4 100644
--- a/frontend/src/lib/admin/stores/backupsStore.js
+++ b/frontend/src/lib/admin/stores/backupsStore.js
@@ -5,8 +5,10 @@ export function createBackupsStore({ api, onToast, at }) {
archives: [],
backupDir: "",
backupsLoading: false,
+ backupsCreating: false,
backupsUploading: false,
backupsRestoring: false,
+ lastCreated: null,
lastRestore: null,
});
@@ -21,13 +23,38 @@ export function createBackupsStore({ api, onToast, at }) {
backupDir: data.backup_dir || "",
}));
} else {
- onToast(data?.message || data?.error || at("backups_load_failed", {}, "Не удалось загрузить бэкапы"));
+ onToast(
+ data?.message ||
+ data?.error ||
+ at("backups_load_failed", {}, "Не удалось загрузить бэкапы")
+ );
}
} finally {
state.update((s) => ({ ...s, backupsLoading: false }));
}
}
+ async function createBackup() {
+ state.update((s) => ({ ...s, backupsCreating: true, lastCreated: null }));
+ try {
+ const data = await api("/admin/backups/create", {
+ method: "POST",
+ });
+ if (data?.ok) {
+ state.update((s) => ({ ...s, lastCreated: data.result || null }));
+ onToast(at("backups_create_done", {}, "Бэкап создан"));
+ await loadArchives();
+ return data.archive || null;
+ }
+ onToast(
+ data?.message || data?.error || at("backups_create_failed", {}, "Не удалось создать бэкап")
+ );
+ return null;
+ } finally {
+ state.update((s) => ({ ...s, backupsCreating: false }));
+ }
+ }
+
async function uploadArchive(file) {
if (!file) return null;
state.update((s) => ({ ...s, backupsUploading: true }));
@@ -43,7 +70,11 @@ export function createBackupsStore({ api, onToast, at }) {
await loadArchives();
return data.archive || null;
}
- onToast(data?.message || data?.error || at("backups_upload_failed", {}, "Не удалось загрузить архив"));
+ onToast(
+ data?.message ||
+ data?.error ||
+ at("backups_upload_failed", {}, "Не удалось загрузить архив")
+ );
return null;
} finally {
state.update((s) => ({ ...s, backupsUploading: false }));
@@ -77,7 +108,9 @@ export function createBackupsStore({ api, onToast, at }) {
onToast(at("backups_restore_done", {}, "Восстановление завершено"));
return true;
}
- onToast(data?.message || data?.error || at("backups_restore_failed", {}, "Не удалось восстановить"));
+ onToast(
+ data?.message || data?.error || at("backups_restore_failed", {}, "Не удалось восстановить")
+ );
return false;
} finally {
state.update((s) => ({ ...s, backupsRestoring: false }));
@@ -87,6 +120,7 @@ export function createBackupsStore({ api, onToast, at }) {
return {
subscribe: state.subscribe,
loadArchives,
+ createBackup,
uploadArchive,
restoreArchive,
};
diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js
index aba1bdc..05e0737 100644
--- a/frontend/src/lib/webapp/mockApi.js
+++ b/frontend/src/lib/webapp/mockApi.js
@@ -475,6 +475,35 @@ export async function mockApi(path, options = {}, context = {}) {
archives: clone(mockBackups),
};
}
+ if (path === "/admin/backups/create") {
+ const createdAt = new Date();
+ const stamp = createdAt
+ .toISOString()
+ .replace(/[-:]/g, "")
+ .replace("T", "-")
+ .replace(/\.\d{3}Z$/, "+0000");
+ const archive = {
+ ...mockBackups[0],
+ name: `remnawave-minishop-backup-${stamp}.zip`,
+ modified_at: createdAt.toISOString(),
+ created_at: createdAt.toISOString(),
+ created_at_local: createdAt.toISOString(),
+ };
+ return {
+ ok: true,
+ archive,
+ result: {
+ archive_name: archive.name,
+ archive_path: `data/backups/${archive.name}`,
+ started_at: createdAt.toISOString(),
+ completed_at: createdAt.toISOString(),
+ db_dump_included: true,
+ compose_files_count: archive.compose_files_count,
+ size_bytes: archive.size_bytes,
+ warnings: [],
+ },
+ };
+ }
if (path === "/admin/backups/upload") {
return {
ok: true,
diff --git a/tests/test_backup_worker.py b/tests/test_backup_worker.py
index 5f5d56b..4fbd3d0 100644
--- a/tests/test_backup_worker.py
+++ b/tests/test_backup_worker.py
@@ -119,6 +119,39 @@ def test_backup_worker_falls_back_to_log_chat_and_thread(tmp_path):
assert send_kwargs["message_thread_id"] == 77
+def test_backup_worker_can_create_manual_backup(tmp_path):
+ compose_dir = tmp_path / "compose"
+ compose_dir.mkdir()
+ settings = _settings(
+ tmp_path,
+ compose_dir,
+ BACKUP_POSTGRES_DUMP_ENABLED=True,
+ BACKUP_COMPOSE_ENABLED=False,
+ )
+ bot = _FakeBot()
+ worker = _FakePgDumpBackupWorker(settings, bot)
+
+ result = asyncio.run(worker.create_backup(backup_type="manual"))
+
+ assert result.archive_path.is_file()
+ assert result.to_payload()["archive_name"] == result.archive_path.name
+ with zipfile.ZipFile(result.archive_path) as archive:
+ manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
+ assert manifest["type"] == "manual"
+ assert "archive" in manifest
+
+
+def test_backup_worker_allocates_unique_archive_path(tmp_path):
+ settings = _settings(tmp_path, tmp_path / "compose")
+ worker = _FakePgDumpBackupWorker(settings, _FakeBot())
+ archive_path = tmp_path / "remnawave-minishop-backup-20260527-120000+0300.zip"
+ archive_path.write_text("existing", encoding="utf-8")
+
+ unique_path = worker._unique_archive_path(archive_path)
+
+ assert unique_path.name == "remnawave-minishop-backup-20260527-120000+0300-2.zip"
+
+
def test_backup_worker_does_not_fail_when_compose_source_is_not_mounted(tmp_path):
missing_compose_dir = tmp_path / "missing-compose"
settings = _settings(
diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py
index 48ca360..c8f41ce 100644
--- a/tests/test_webapp_route_contract.py
+++ b/tests/test_webapp_route_contract.py
@@ -187,6 +187,7 @@ class WebAppRouteContractTests(unittest.TestCase):
("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route",
("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_upload_route",
("GET", "/api/admin/backups"): "admin_backups_list_route",
+ ("POST", "/api/admin/backups/create"): "admin_backups_create_route",
("POST", "/api/admin/backups/upload"): "admin_backups_upload_route",
("POST", "/api/admin/backups/restore"): "admin_backups_restore_route",
("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",
|