Files
web-app-launcher/src/lib/components/admin/BackupPanel.svelte
T
alexei.dolgolyov b0439e39c4 feat(backup): replace JSON import/export with SQLite database backup system
Replace the JSON-based import/export with a proper backup system that copies
the SQLite database file directly. Supports manual on-demand backups, periodic
scheduled backups via node-cron, configurable retention, file download, and
full database restore.

- Add backupService with VACUUM INTO for safe DB copies
- Add backupScheduler following healthcheckScheduler pattern
- Add 6 admin API endpoints (create, list, download, restore, delete, schedule)
- Add BackupPanel UI with backup table, confirmation dialogs, schedule config
- Add backup fields to SystemSettings schema
- Remove old ImportExportPanel, exportService, importService, and related code
2026-04-02 23:16:18 +03:00

429 lines
13 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte';
import { t } from 'svelte-i18n';
interface BackupInfo {
filename: string;
size: number;
createdAt: string;
}
interface BackupSchedule {
backupEnabled: boolean;
backupCronExpression: string;
backupMaxCount: number;
}
type CronPreset = 'daily' | 'twice_daily' | 'weekly' | 'custom';
let backups: BackupInfo[] = $state([]);
let schedule: BackupSchedule = $state({
backupEnabled: false,
backupCronExpression: '0 3 * * *',
backupMaxCount: 10
});
let creating = $state(false);
let savingSchedule = $state(false);
let restoringFilename: string | null = $state(null);
let deletingFilename: string | null = $state(null);
let confirmRestore: string | null = $state(null);
let confirmDelete: string | null = $state(null);
let statusMessage = $state('');
let statusType: 'success' | 'error' | '' = $state('');
let customCron = $state('');
let cronPreset: CronPreset = $state('daily');
const CRON_PRESETS: Record<string, string> = {
daily: '0 3 * * *',
twice_daily: '0 */12 * * *',
weekly: '0 3 * * 0'
};
function clearStatus() {
statusMessage = '';
statusType = '';
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString();
}
function detectPreset(cronExpr: string): CronPreset {
for (const [key, value] of Object.entries(CRON_PRESETS)) {
if (cronExpr === value) return key as CronPreset;
}
return 'custom';
}
async function loadBackups() {
try {
const response = await fetch('/api/admin/backups');
const result = await response.json();
if (result.success) {
backups = result.data.backups;
schedule = result.data.schedule;
cronPreset = detectPreset(schedule.backupCronExpression);
if (cronPreset === 'custom') {
customCron = schedule.backupCronExpression;
}
}
} catch {
// Fail silently on initial load
}
}
async function handleCreate() {
clearStatus();
creating = true;
try {
const response = await fetch('/api/admin/backups', { method: 'POST' });
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to create backup');
}
statusMessage = $t('admin.backup_create_success');
statusType = 'success';
await loadBackups();
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to create backup';
statusType = 'error';
} finally {
creating = false;
}
}
async function handleDownload(filename: string) {
const a = document.createElement('a');
a.href = `/api/admin/backups/${encodeURIComponent(filename)}/download`;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
async function handleRestore(filename: string) {
clearStatus();
confirmRestore = null;
restoringFilename = filename;
try {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}/restore`, {
method: 'POST'
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to restore backup');
}
statusMessage = $t('admin.backup_restore_success');
statusType = 'success';
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
statusType = 'error';
} finally {
restoringFilename = null;
}
}
async function handleDelete(filename: string) {
clearStatus();
confirmDelete = null;
deletingFilename = filename;
try {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}`, {
method: 'DELETE'
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to delete backup');
}
statusMessage = $t('admin.backup_delete_success');
statusType = 'success';
await loadBackups();
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to delete backup';
statusType = 'error';
} finally {
deletingFilename = null;
}
}
async function handleSaveSchedule() {
clearStatus();
savingSchedule = true;
try {
const cronExpression = cronPreset === 'custom' ? customCron : CRON_PRESETS[cronPreset];
const response = await fetch('/api/admin/backups/schedule', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
backupEnabled: schedule.backupEnabled,
backupCronExpression: cronExpression,
backupMaxCount: schedule.backupMaxCount
})
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to save schedule');
}
schedule = result.data;
statusMessage = $t('admin.backup_schedule_saved');
statusType = 'success';
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to save schedule';
statusType = 'error';
} finally {
savingSchedule = false;
}
}
// Load backups on mount (untrack to avoid infinite re-trigger)
$effect(() => {
untrack(() => loadBackups());
});
</script>
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-1 text-lg font-semibold text-card-foreground">{$t('admin.backup_title')}</h2>
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.backup_description')}</p>
<!-- Create Backup -->
<div class="mb-6">
<button
type="button"
onclick={handleCreate}
disabled={creating}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{creating ? $t('admin.backup_creating') : $t('admin.backup_create')}
</button>
</div>
<!-- Backup List -->
<div class="mb-6">
<h3 class="mb-3 text-sm font-medium text-foreground">{$t('admin.backup_list_title')}</h3>
{#if backups.length === 0}
<p class="text-sm text-muted-foreground">{$t('admin.backup_list_empty')}</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-border text-xs uppercase text-muted-foreground">
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_filename')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_size')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_date')}</th>
<th class="pb-2 font-medium">{$t('admin.backup_actions')}</th>
</tr>
</thead>
<tbody>
{#each backups as backup (backup.filename)}
<tr class="border-b border-border/50">
<td class="py-2.5 pr-4 font-mono text-xs text-foreground">{backup.filename}</td>
<td class="py-2.5 pr-4 text-muted-foreground">{formatBytes(backup.size)}</td>
<td class="py-2.5 pr-4 text-muted-foreground">{formatDate(backup.createdAt)}</td>
<td class="py-2.5">
<div class="flex gap-2">
<button
type="button"
onclick={() => handleDownload(backup.filename)}
class="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10"
>
{$t('admin.backup_download')}
</button>
<button
type="button"
onclick={() => (confirmRestore = backup.filename)}
disabled={restoringFilename === backup.filename}
class="rounded px-2 py-1 text-xs font-medium text-amber-600 hover:bg-amber-600/10 disabled:opacity-50 dark:text-amber-400"
>
{restoringFilename === backup.filename
? '...'
: $t('admin.backup_restore')}
</button>
<button
type="button"
onclick={() => (confirmDelete = backup.filename)}
disabled={deletingFilename === backup.filename}
class="rounded px-2 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
{deletingFilename === backup.filename
? '...'
: $t('admin.backup_delete')}
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Restore Confirmation Dialog -->
{#if confirmRestore}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
{$t('admin.backup_restore_confirm_title')}
</h3>
<p class="mb-4 text-sm text-muted-foreground">
{$t('admin.backup_restore_confirm')}
</p>
<p class="mb-4 font-mono text-xs text-foreground">{confirmRestore}</p>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => (confirmRestore = null)}
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="button"
onclick={() => confirmRestore && handleRestore(confirmRestore)}
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
>
{$t('admin.backup_restore')}
</button>
</div>
</div>
</div>
{/if}
<!-- Delete Confirmation Dialog -->
{#if confirmDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
{$t('admin.backup_delete_confirm_title')}
</h3>
<p class="mb-4 text-sm text-muted-foreground">
{$t('admin.backup_delete_confirm')}
</p>
<p class="mb-4 font-mono text-xs text-foreground">{confirmDelete}</p>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => (confirmDelete = null)}
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="button"
onclick={() => confirmDelete && handleDelete(confirmDelete)}
class="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
>
{$t('admin.backup_delete')}
</button>
</div>
</div>
</div>
{/if}
<!-- Divider -->
<div class="my-6 border-t border-border"></div>
<!-- Schedule Configuration -->
<div>
<h3 class="mb-3 text-sm font-medium text-foreground">{$t('admin.backup_schedule_title')}</h3>
<div class="space-y-4">
<!-- Enable toggle -->
<label class="flex items-center gap-3">
<input
type="checkbox"
bind:checked={schedule.backupEnabled}
class="h-4 w-4 rounded border-border text-primary focus:ring-ring"
/>
<span class="text-sm text-foreground">{$t('admin.backup_schedule_enabled')}</span>
</label>
{#if schedule.backupEnabled}
<!-- Cron preset -->
<div>
<label for="cron-preset" class="mb-1 block text-sm text-muted-foreground">
{$t('admin.backup_schedule_cron')}
</label>
<select
id="cron-preset"
bind:value={cronPreset}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-auto"
>
<option value="daily">{$t('admin.backup_schedule_preset_daily')}</option>
<option value="twice_daily">{$t('admin.backup_schedule_preset_twice_daily')}</option>
<option value="weekly">{$t('admin.backup_schedule_preset_weekly')}</option>
<option value="custom">{$t('admin.backup_schedule_preset_custom')}</option>
</select>
</div>
{#if cronPreset === 'custom'}
<div>
<input
type="text"
bind:value={customCron}
placeholder="0 3 * * *"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-64"
/>
</div>
{/if}
<!-- Max count -->
<div>
<label for="max-count" class="mb-1 block text-sm text-muted-foreground">
{$t('admin.backup_schedule_max_count')}
</label>
<input
id="max-count"
type="number"
bind:value={schedule.backupMaxCount}
min="1"
max="100"
class="w-24 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
</div>
{/if}
<button
type="button"
onclick={handleSaveSchedule}
disabled={savingSchedule}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{savingSchedule ? $t('admin.backup_schedule_saving') : $t('admin.backup_schedule_save')}
</button>
</div>
</div>
<!-- Status message -->
{#if statusMessage}
<div
class="mt-4 rounded-md p-3 text-sm {statusType === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}"
>
{statusMessage}
</div>
{/if}
</section>