feat: add backups feature
This commit is contained in:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user