b0439e39c4
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
429 lines
13 KiB
Svelte
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>
|