feat: add backups feature
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createBackupsStore({ api, onToast, at }) {
|
||||
const state = writable({
|
||||
archives: [],
|
||||
backupDir: "",
|
||||
backupsLoading: false,
|
||||
backupsUploading: false,
|
||||
backupsRestoring: false,
|
||||
lastRestore: null,
|
||||
});
|
||||
|
||||
async function loadArchives() {
|
||||
state.update((s) => ({ ...s, backupsLoading: true }));
|
||||
try {
|
||||
const data = await api("/admin/backups");
|
||||
if (data?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
archives: data.archives || [],
|
||||
backupDir: data.backup_dir || "",
|
||||
}));
|
||||
} else {
|
||||
onToast(data?.message || data?.error || at("backups_load_failed", {}, "Не удалось загрузить бэкапы"));
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, backupsLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadArchive(file) {
|
||||
if (!file) return null;
|
||||
state.update((s) => ({ ...s, backupsUploading: true }));
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const data = await api("/admin/backups/upload", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (data?.ok) {
|
||||
onToast(at("backups_upload_done", {}, "Архив загружен"));
|
||||
await loadArchives();
|
||||
return data.archive || null;
|
||||
}
|
||||
onToast(data?.message || data?.error || at("backups_upload_failed", {}, "Не удалось загрузить архив"));
|
||||
return null;
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, backupsUploading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreArchive({ archiveName, restoreDatabase, restoreCompose }) {
|
||||
const archive_name = String(archiveName || "").trim();
|
||||
if (!archive_name) {
|
||||
onToast(at("backups_select_archive", {}, "Выберите архив"));
|
||||
return false;
|
||||
}
|
||||
if (!restoreDatabase && !restoreCompose) {
|
||||
onToast(at("backups_select_target", {}, "Выберите, что восстановить"));
|
||||
return false;
|
||||
}
|
||||
|
||||
state.update((s) => ({ ...s, backupsRestoring: true, lastRestore: null }));
|
||||
try {
|
||||
const data = await api("/admin/backups/restore", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
archive_name,
|
||||
restore_database: Boolean(restoreDatabase),
|
||||
restore_compose: Boolean(restoreCompose),
|
||||
confirm: true,
|
||||
}),
|
||||
});
|
||||
if (data?.ok) {
|
||||
state.update((s) => ({ ...s, lastRestore: data.result || null }));
|
||||
onToast(at("backups_restore_done", {}, "Восстановление завершено"));
|
||||
return true;
|
||||
}
|
||||
onToast(data?.message || data?.error || at("backups_restore_failed", {}, "Не удалось восстановить"));
|
||||
return false;
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, backupsRestoring: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
loadArchives,
|
||||
uploadArchive,
|
||||
restoreArchive,
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export {
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
TriangleAlert,
|
||||
Upload,
|
||||
User,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
|
||||
@@ -270,6 +270,34 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
const mockBackups = [
|
||||
{
|
||||
name: "remnawave-minishop-backup-20260527-120000+0300.zip",
|
||||
size_bytes: 184320,
|
||||
modified_at: "2026-05-27T09:00:00Z",
|
||||
created_at: "2026-05-27T09:00:00Z",
|
||||
created_at_local: "2026-05-27T12:00:00+03:00",
|
||||
has_database: true,
|
||||
has_compose: true,
|
||||
database_name: "remnawave_minishop",
|
||||
compose_files_count: 6,
|
||||
warnings: [],
|
||||
manifest: {},
|
||||
},
|
||||
{
|
||||
name: "remnawave-minishop-backup-20260527-110000+0300.zip",
|
||||
size_bytes: 153600,
|
||||
modified_at: "2026-05-27T08:00:00Z",
|
||||
created_at: "2026-05-27T08:00:00Z",
|
||||
created_at_local: "2026-05-27T11:00:00+03:00",
|
||||
has_database: true,
|
||||
has_compose: false,
|
||||
database_name: "remnawave_minishop",
|
||||
compose_files_count: 0,
|
||||
warnings: ["Compose source directory is unavailable"],
|
||||
manifest: {},
|
||||
},
|
||||
];
|
||||
if (path === "/admin/stats") {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -440,6 +468,41 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/admin/backups") {
|
||||
return {
|
||||
ok: true,
|
||||
backup_dir: "data/backups",
|
||||
archives: clone(mockBackups),
|
||||
};
|
||||
}
|
||||
if (path === "/admin/backups/upload") {
|
||||
return {
|
||||
ok: true,
|
||||
archive: {
|
||||
...mockBackups[0],
|
||||
name: `remnawave-minishop-backup-uploaded-${Date.now()}.zip`,
|
||||
modified_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
created_at_local: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/admin/backups/restore") {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
archive_name: mockBackups[0].name,
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString(),
|
||||
database_restored: true,
|
||||
compose_files_restored: 6,
|
||||
compose_target_dir: "/app/compose-source",
|
||||
compose_pre_restore_archive:
|
||||
"data/backups/remnawave-minishop-compose-pre-restore-20260527-121500+0300.zip",
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") {
|
||||
try {
|
||||
const body = options?.body ? JSON.parse(String(options.body)) : {};
|
||||
|
||||
Reference in New Issue
Block a user