feat: add backups feature

This commit is contained in:
3252a8
2026-05-27 13:53:30 +03:00
parent e90988ea5c
commit 3aede8fe95
55 changed files with 3032 additions and 70 deletions
+14
View File
@@ -5,6 +5,7 @@
ChevronsUpDown,
Coins,
CreditCard,
Database,
Download,
FileText,
Globe2,
@@ -29,6 +30,7 @@
import BrandMark from "$lib/webapp/BrandMark.svelte";
import AdsSection from "./sections/AdsSection.svelte";
import BackupsSection from "./sections/BackupsSection.svelte";
import BroadcastSection from "./sections/BroadcastSection.svelte";
import LogsSection from "./sections/LogsSection.svelte";
import PaymentDetailModal from "./sections/PaymentDetailModal.svelte";
@@ -44,6 +46,7 @@
import UserDetailModal from "./sections/UserDetailModal.svelte";
import UsersSection from "./sections/UsersSection.svelte";
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
import { createBackupsStore } from "../lib/admin/stores/backupsStore.js";
import { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js";
import { createLogsStore } from "../lib/admin/stores/logsStore.js";
import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js";
@@ -133,6 +136,7 @@
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
{ id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
{ id: "translations", label: at("nav_translations", {}, "Переводы"), icon: Languages },
{ id: "backups", label: at("nav_backups", {}, "Бэкапы"), icon: Database },
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
],
},
@@ -191,6 +195,10 @@
"Оверрайды строк локализации из базы данных и data/locales-overrides.json"
),
},
backups: {
title: at("section_backups_title", {}, "Бэкапы"),
subtitle: at("section_backups_subtitle", {}, "Архивы, загрузка и восстановление БД/compose"),
},
settings: {
title: at("section_settings_title", {}, "Настройки приложения"),
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
@@ -227,6 +235,7 @@
}
const adsStore = createAdsStore({ api, onToast: flash, at });
const backupsStore = createBackupsStore({ api, onToast: flash, at });
const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
const logsStore = createLogsStore({ api, at });
const paymentsStore = createPaymentsStore({ api, onToast: flash, at });
@@ -241,6 +250,7 @@
setContext("promosStore", promosStore);
setContext("adsStore", adsStore);
setContext("backupsStore", backupsStore);
setContext("broadcastStore", broadcastStore);
setContext("logsStore", logsStore);
setContext("paymentsStore", paymentsStore);
@@ -790,6 +800,10 @@
<SettingsSection {at} {onSettingsSaved} {currentLang} />
{/if}
{#if active === "backups"}
<BackupsSection {at} {fmtDate} />
{/if}
{#if active === "translations"}
<TranslationsSection {at} {onTranslationsSaved} />
{/if}
@@ -0,0 +1,351 @@
<script>
import { getContext, onMount } from "svelte";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
import {
CheckCircle2,
Database,
RefreshCw,
Server,
TriangleAlert,
Upload,
} from "$components/ui/icons.js";
export let at = (key) => key;
export let fmtDate = (value) => value;
const backupsStore = getContext("backupsStore");
let selectedName = "";
let restoreDatabase = true;
let restoreCompose = false;
let fileInput = null;
$: ({
archives,
backupDir,
backupsLoading,
backupsUploading,
backupsRestoring,
lastRestore,
} = $backupsStore);
$: if (!selectedName && archives?.length) selectedName = archives[0].name;
$: if (selectedName && archives?.length && !archives.some((item) => item.name === selectedName)) {
selectedName = archives[0].name;
}
$: selectedArchive = (archives || []).find((item) => item.name === selectedName) || null;
$: if (selectedArchive && restoreDatabase && !selectedArchive.has_database) restoreDatabase = false;
$: if (selectedArchive && restoreCompose && !selectedArchive.has_compose) restoreCompose = false;
$: if (selectedArchive && !restoreDatabase && !restoreCompose) {
if (selectedArchive.has_database) restoreDatabase = true;
else if (selectedArchive.has_compose) restoreCompose = true;
}
$: canRestore = Boolean(selectedArchive && (restoreDatabase || restoreCompose) && !backupsRestoring);
$: backupHeaders = [
"",
at("backups_col_archive", {}, "Архив"),
at("backups_col_created", {}, "Создан"),
at("backups_col_size", {}, "Размер"),
at("backups_col_contents", {}, "Состав"),
at("backups_col_warnings", {}, "Предупреждения"),
];
function formatSize(sizeBytes) {
const units = ["B", "KB", "MB", "GB"];
let value = Number(sizeBytes || 0);
let unit = units[0];
for (unit of units) {
if (value < 1024 || unit === "GB") break;
value /= 1024;
}
return unit === "B" ? `${Math.round(value)} ${unit}` : `${value.toFixed(1)} ${unit}`;
}
function archiveDate(archive) {
return archive?.created_at_local || archive?.created_at || archive?.modified_at || "";
}
function selectedComponentsText() {
const parts = [];
if (restoreDatabase) parts.push(at("backups_target_database", {}, "БД"));
if (restoreCompose) parts.push(at("backups_target_compose", {}, "compose-папку"));
return parts.join(" + ");
}
async function uploadSelectedFile(event) {
const file = event?.currentTarget?.files?.[0];
if (!file) return;
const archive = await backupsStore.uploadArchive(file);
if (archive?.name) selectedName = archive.name;
event.currentTarget.value = "";
}
async function restoreSelected() {
if (!canRestore) return;
const confirmText = at(
"backups_restore_confirm",
{ name: selectedName, components: selectedComponentsText() },
`Запустить восстановление из ${selectedName}?`
);
if (typeof window !== "undefined" && !window.confirm(confirmText)) return;
const ok = await backupsStore.restoreArchive({
archiveName: selectedName,
restoreDatabase,
restoreCompose,
});
if (ok) await backupsStore.loadArchives();
}
onMount(() => {
backupsStore.loadArchives();
});
</script>
<div class="backups-layout">
<div class="admin-toolbar admin-toolbar-card backups-toolbar">
<div class="backups-toolbar-main">
<AdminButton onclick={() => backupsStore.loadArchives()} disabled={backupsLoading}>
<RefreshCw size={14} />
{at("btn_refresh", {}, "Обновить")}
</AdminButton>
<AdminButton onclick={() => fileInput?.click()} disabled={backupsUploading}>
<Upload size={14} />
{backupsUploading
? at("backups_uploading", {}, "Загрузка...")
: at("backups_upload", {}, "Загрузить архив")}
</AdminButton>
<input
bind:this={fileInput}
class="backups-file-input"
type="file"
accept=".zip,application/zip"
on:change={uploadSelectedFile}
/>
</div>
<div class="admin-toolbar-summary">
<span class="admin-toolbar-field-label">{at("backups_dir", {}, "Каталог")}</span>
<strong class="backups-dir">{backupDir || "data/backups"}</strong>
</div>
</div>
<article class="admin-card backups-restore-card">
<header class="admin-card-head">
<div>
<h3>{at("backups_restore_title", {}, "Восстановление")}</h3>
{#if selectedArchive}
<small class="backups-selected-name">{selectedArchive.name}</small>
{/if}
</div>
{#if lastRestore}
<AdminBadge variant="success">
<CheckCircle2 size={12} />
{at("backups_last_restore_done", {}, "Готово")}
</AdminBadge>
{/if}
</header>
<div class="admin-card-body backups-restore-body">
<label class="backups-check" class:is-disabled={!selectedArchive?.has_database}>
<input
type="checkbox"
bind:checked={restoreDatabase}
disabled={!selectedArchive?.has_database || backupsRestoring}
/>
<Database size={16} />
<span>{at("backups_target_database", {}, "БД")}</span>
</label>
<label class="backups-check" class:is-disabled={!selectedArchive?.has_compose}>
<input
type="checkbox"
bind:checked={restoreCompose}
disabled={!selectedArchive?.has_compose || backupsRestoring}
/>
<Server size={16} />
<span>{at("backups_target_compose", {}, "compose-папка")}</span>
</label>
<AdminButton variant="danger" onclick={restoreSelected} disabled={!canRestore}>
<RefreshCw size={14} />
{backupsRestoring
? at("backups_restoring", {}, "Восстановление...")
: at("backups_restore_run", {}, "Запустить")}
</AdminButton>
</div>
{#if lastRestore?.compose_pre_restore_archive}
<div class="backups-restore-note">
{at(
"backups_pre_restore_snapshot",
{ path: lastRestore.compose_pre_restore_archive },
"Текущая compose-папка сохранена перед заменой."
)}
</div>
{/if}
</article>
<div class="admin-table-wrap">
{#if backupsLoading}
<AdminTableSkeleton
headers={backupHeaders}
rows={6}
widths={["36px", "minmax(220px, 1fr)", "150px", "80px", "150px", "120px"]}
/>
{:else if !archives?.length}
<AdminEmptyState tone="card">
<span class="admin-muted">{at("backups_empty", {}, "Архивов пока нет")}</span>
</AdminEmptyState>
{:else}
<AdminTable class="backups-table">
<thead>
<tr>
<th aria-label={at("select", {}, "Выбрать")}></th>
<th>{at("backups_col_archive", {}, "Архив")}</th>
<th>{at("backups_col_created", {}, "Создан")}</th>
<th>{at("backups_col_size", {}, "Размер")}</th>
<th>{at("backups_col_contents", {}, "Состав")}</th>
<th>{at("backups_col_warnings", {}, "Предупреждения")}</th>
</tr>
</thead>
<tbody>
{#each archives as archive (archive.name)}
<tr class:is-selected={archive.name === selectedName}>
<td data-label={at("select", {}, "Выбрать")}>
<input
type="radio"
name="backup-archive"
value={archive.name}
checked={archive.name === selectedName}
on:change={() => (selectedName = archive.name)}
aria-label={archive.name}
/>
</td>
<td class="admin-cell-wrap backups-name" data-label={at("backups_col_archive", {}, "Архив")}>
{archive.name}
</td>
<td data-label={at("backups_col_created", {}, "Создан")}>{fmtDate(archiveDate(archive))}</td>
<td data-label={at("backups_col_size", {}, "Размер")}>{formatSize(archive.size_bytes)}</td>
<td data-label={at("backups_col_contents", {}, "Состав")}>
<span class="backups-badges">
{#if archive.has_database}
<AdminBadge variant="success">{at("backups_badge_db", {}, "БД")}</AdminBadge>
{/if}
{#if archive.has_compose}
<AdminBadge variant="muted">
{at("backups_badge_compose", {}, "Compose")}
</AdminBadge>
{/if}
</span>
</td>
<td data-label={at("backups_col_warnings", {}, "Предупреждения")}>
{#if archive.warnings?.length}
<AdminBadge variant="warning">
<TriangleAlert size={12} />
{archive.warnings.length}
</AdminBadge>
{:else}
<span class="admin-muted">-</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</div>
</div>
<style>
.backups-layout {
display: grid;
gap: 12px;
}
.backups-toolbar-main {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.backups-file-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.backups-dir,
.backups-selected-name,
.backups-name {
font-family: var(--font-mono);
word-break: break-word;
}
.backups-dir {
max-width: min(420px, 70vw);
overflow: hidden;
text-overflow: ellipsis;
}
.backups-restore-body {
display: grid;
grid-template-columns: repeat(2, minmax(160px, 1fr)) auto;
gap: 10px;
align-items: center;
}
.backups-check {
display: flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-surface-2);
color: var(--admin-text);
font-size: 13px;
}
.backups-check input {
width: 16px;
height: 16px;
margin: 0;
}
.backups-check.is-disabled {
opacity: 0.55;
}
.backups-restore-note {
border-top: 1px solid var(--admin-border);
padding: 10px 14px;
color: var(--admin-muted);
font-size: 12px;
}
:global(.backups-table tbody tr.is-selected) {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.backups-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
@media (max-width: 760px) {
.backups-restore-body {
grid-template-columns: minmax(0, 1fr);
}
:global(.backups-restore-body .admin-btn) {
width: 100%;
}
}
</style>
@@ -339,6 +339,7 @@
trial: "Триал",
referral: "Реферальная программа",
notifications: "Уведомления",
backups: "Бэкапы",
support: "Поддержка",
devices: "Устройства",
subscription_guides: "Connection guides",
@@ -560,6 +561,8 @@
class="input"
type="number"
step={field.type === "float" ? "0.1" : "1"}
min={field.min ?? undefined}
max={field.max ?? undefined}
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
@@ -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,
};
}
+1
View File
@@ -63,6 +63,7 @@ export {
TrendingDown,
TrendingUp,
TriangleAlert,
Upload,
User,
UserMinus,
UserPlus,
+63
View File
@@ -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)) : {};