711f218622
Comprehensive pre-production sweep across the Aurora redesign — drives svelte-check to 0 errors / 0 warnings (was 61) without changing visual intent. Highlights: - Mobile: hero title shrinks at 480px, signal-list stacks timestamp under sentence below 640px, sidebar icon buttons bumped to 40x40 - Light theme: muted-foreground darkened to #3a3560 to clear WCAG AA on glass surfaces and the modal close button - Perf: topbar backdrop-filter 28→14px, mobile-more sheet 28→12px to cut concurrent blur layers on mid-tier mobile - a11y: prefers-reduced-motion mute for aurora drift / pulses / shimmer / stagger; aria-label on every icon-only button; aria-describedby on Hint; combobox/listbox/aria-activedescendant on SearchPalette; modal dialog tabindex; 47 label-without-control warnings across 14 form pages cleaned up via for=/id= or label→div - Dashboard derived state split into topology- vs status-bound layers so polling no longer re-runs the full provider/wires computation - Mobile bottom nav derived from baseNavEntries by key lookup so adding a top-level nav entry keeps the two trees in sync - Bug: template-configs page now respects the global provider filter for both the count meter and the type pill (was reading the unfiltered cache) - Misc: portal EventChart tooltip and switch its swatches to Aurora tokens; CollapsibleSlot warning state uses warning-fg/-bg tokens instead of #d97706; Hint z-index 99999→9999; element refs across Modal/EntitySelect/MultiEntitySelect/SearchPalette/IconGridSelect/ Hint/targets converted to \$state for reactivity; 4 dead .topbar-cta selectors removed
717 lines
25 KiB
Svelte
717 lines
25 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { api, fetchAuth } from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import Loading from '$lib/components/Loading.svelte';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
import Button from '$lib/components/Button.svelte';
|
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
|
|
|
// --- Export state ---
|
|
let exportSecrets = $state('exclude');
|
|
let exporting = $state(false);
|
|
|
|
const categories = [
|
|
{ key: 'providers', label: 'backup.catProviders' },
|
|
{ key: 'telegram_bots', label: 'backup.catTelegramBots' },
|
|
{ key: 'matrix_bots', label: 'backup.catMatrixBots' },
|
|
{ key: 'email_bots', label: 'backup.catEmailBots' },
|
|
{ key: 'targets', label: 'backup.catTargets' },
|
|
{ key: 'tracking_configs', label: 'backup.catTrackingConfigs' },
|
|
{ key: 'template_configs', label: 'backup.catTemplateConfigs' },
|
|
{ key: 'command_configs', label: 'backup.catCommandConfigs' },
|
|
{ key: 'command_template_configs', label: 'backup.catCommandTemplateConfigs' },
|
|
{ key: 'notification_trackers', label: 'backup.catNotificationTrackers' },
|
|
{ key: 'command_trackers', label: 'backup.catCommandTrackers' },
|
|
{ key: 'actions', label: 'backup.catActions' },
|
|
{ key: 'app_settings', label: 'backup.catAppSettings' },
|
|
];
|
|
let selectedCategories = $state<Record<string, boolean>>(
|
|
Object.fromEntries(categories.map(c => [c.key, true]))
|
|
);
|
|
|
|
// --- Import state ---
|
|
let importFile: File | null = $state(null);
|
|
let importConflict = $state('skip');
|
|
let importing = $state(false);
|
|
let validating = $state(false);
|
|
let validationResult: any = $state(null);
|
|
let importResult: any = $state(null);
|
|
let confirmImportOpen = $state(false);
|
|
let confirmExportOpen = $state(false);
|
|
|
|
// --- Scheduled backup state ---
|
|
let loaded = $state(false);
|
|
let error = $state('');
|
|
let scheduledSettings = $state({
|
|
backup_scheduled_enabled: 'false',
|
|
backup_scheduled_interval_hours: '24',
|
|
backup_secrets_mode: 'exclude',
|
|
backup_retention_count: '5',
|
|
});
|
|
let savingSchedule = $state(false);
|
|
|
|
// --- Backup files ---
|
|
let backupFiles = $state<any[]>([]);
|
|
let loadingFiles = $state(false);
|
|
let confirmDeleteFile = $state('');
|
|
let creatingBackup = $state(false);
|
|
|
|
// --- Pending restore state ---
|
|
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
|
|
let postRestoreModalOpen = $state(false);
|
|
let restartingOverlay = $state(false);
|
|
|
|
onMount(async () => {
|
|
try {
|
|
const [settings, files, p] = await Promise.all([
|
|
api('/backup/scheduled'),
|
|
api('/backup/files'),
|
|
api('/backup/pending-restore'),
|
|
]);
|
|
scheduledSettings = settings;
|
|
backupFiles = files;
|
|
pending = p;
|
|
} catch (err: any) {
|
|
error = err.message;
|
|
snackError(err.message);
|
|
} finally {
|
|
loaded = true;
|
|
}
|
|
});
|
|
|
|
async function cancelPending() {
|
|
try {
|
|
await api('/backup/pending-restore', { method: 'DELETE' });
|
|
snackSuccess(t('backup.pendingCancelled'));
|
|
pending = null;
|
|
} catch (err: any) { snackError(err.message); }
|
|
}
|
|
|
|
async function applyAndRestart() {
|
|
try {
|
|
await api('/backup/apply-restart', { method: 'POST' });
|
|
restartingOverlay = true;
|
|
// Poll /health until the new instance is up
|
|
const startedAt = Date.now();
|
|
let attempts = 0;
|
|
const poll = async () => {
|
|
attempts += 1;
|
|
try {
|
|
const res = await fetch('/api/health');
|
|
if (res.ok && Date.now() - startedAt > 2000) {
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
} catch { /* still down */ }
|
|
if (attempts < 120) setTimeout(poll, 1000);
|
|
};
|
|
setTimeout(poll, 1500);
|
|
} catch (err: any) {
|
|
restartingOverlay = false;
|
|
snackError(err.message);
|
|
}
|
|
}
|
|
|
|
async function createManualBackup() {
|
|
creatingBackup = true;
|
|
try {
|
|
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
|
|
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
|
|
snackSuccess(t('backup.manualCreated'));
|
|
await refreshFiles();
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
} finally {
|
|
creatingBackup = false;
|
|
}
|
|
}
|
|
|
|
// --- Export ---
|
|
async function doExport() {
|
|
if (exportSecrets === 'include') {
|
|
confirmExportOpen = true;
|
|
return;
|
|
}
|
|
await performExport();
|
|
}
|
|
|
|
async function performExport() {
|
|
confirmExportOpen = false;
|
|
exporting = true;
|
|
try {
|
|
const cats = Object.entries(selectedCategories)
|
|
.filter(([_, v]) => v)
|
|
.map(([k]) => k)
|
|
.join(',');
|
|
const data = await api(`/backup/export?secrets_mode=${exportSecrets}&categories=${cats}`);
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
a.download = `notify-bridge-backup-${ts}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
snackSuccess(t('backup.exportSuccess'));
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
} finally {
|
|
exporting = false;
|
|
}
|
|
}
|
|
|
|
// --- Validate ---
|
|
async function validateFile() {
|
|
if (!importFile) return;
|
|
validating = true;
|
|
validationResult = null;
|
|
importResult = null;
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', importFile);
|
|
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
|
|
validationResult = await res.json();
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
} finally {
|
|
validating = false;
|
|
}
|
|
}
|
|
|
|
// --- Import ---
|
|
async function doImport() {
|
|
confirmImportOpen = true;
|
|
}
|
|
|
|
async function performImport() {
|
|
confirmImportOpen = false;
|
|
if (!importFile) return;
|
|
importing = true;
|
|
importResult = null;
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', importFile);
|
|
const res = await fetchAuth(`/backup/prepare-restore?conflict_mode=${importConflict}`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
importResult = await res.json();
|
|
pending = importResult;
|
|
snackSuccess(t('backup.restorePrepared'));
|
|
postRestoreModalOpen = true;
|
|
importFile = null;
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
} finally {
|
|
importing = false;
|
|
}
|
|
}
|
|
|
|
// --- Scheduled settings ---
|
|
async function saveSchedule() {
|
|
savingSchedule = true;
|
|
try {
|
|
scheduledSettings = await api('/backup/scheduled', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(scheduledSettings),
|
|
});
|
|
snackSuccess(t('backup.scheduleSaved'));
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
} finally {
|
|
savingSchedule = false;
|
|
}
|
|
}
|
|
|
|
// --- File management ---
|
|
async function refreshFiles() {
|
|
loadingFiles = true;
|
|
try {
|
|
backupFiles = await api('/backup/files');
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
} finally {
|
|
loadingFiles = false;
|
|
}
|
|
}
|
|
|
|
async function downloadFile(filename: string) {
|
|
try {
|
|
const data = await api(`/backup/files/${filename}`);
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
}
|
|
}
|
|
|
|
async function deleteFile(filename: string) {
|
|
try {
|
|
await api(`/backup/files/${filename}`, { method: 'DELETE' });
|
|
snackSuccess(t('backup.fileDeleted'));
|
|
confirmDeleteFile = '';
|
|
await refreshFiles();
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
}
|
|
}
|
|
|
|
function handleFileSelect(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
if (input.files?.length) {
|
|
importFile = input.files[0];
|
|
validationResult = null;
|
|
importResult = null;
|
|
}
|
|
}
|
|
|
|
function formatSize(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`;
|
|
}
|
|
|
|
let allSelected = $derived(Object.values(selectedCategories).every(v => v));
|
|
let noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
|
|
|
|
function toggleAll() {
|
|
const newVal = !allSelected;
|
|
for (const key of Object.keys(selectedCategories)) {
|
|
selectedCategories[key] = newVal;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<PageHeader
|
|
title={t('backup.title')}
|
|
emphasis={t('backup.titleEmphasis')}
|
|
description={t('backup.description')}
|
|
crumb="System · Maintenance"
|
|
/>
|
|
|
|
{#if !loaded}
|
|
<Loading />
|
|
{:else}
|
|
<ErrorBanner message={error} />
|
|
|
|
{#if pending?.pending}
|
|
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
|
|
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
|
|
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
|
|
<MdiIcon name="mdiClockAlert" size={20} />
|
|
</span>
|
|
<div class="flex-1 min-w-[12rem] text-sm">
|
|
<div class="font-medium">{t('backup.pendingTitle')}</div>
|
|
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
|
|
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
{#if pending.supervised}
|
|
<Button size="sm" onclick={applyAndRestart}>
|
|
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
|
</Button>
|
|
{/if}
|
|
<button onclick={cancelPending}
|
|
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
|
{t('common.cancel')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="space-y-6">
|
|
|
|
<!-- Export Section -->
|
|
<Card>
|
|
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
<MdiIcon name="mdiDatabaseExport" size={18} />
|
|
{t('backup.export')}
|
|
</h3>
|
|
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.exportDescription')}</p>
|
|
|
|
<!-- Categories -->
|
|
<div class="mb-4">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="text-xs font-medium">{t('backup.categories')}</span>
|
|
<button class="text-xs underline" style="color: var(--color-primary);" onclick={toggleAll}>
|
|
{allSelected ? t('backup.deselectAll') : t('backup.selectAll')}
|
|
</button>
|
|
</div>
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
|
{#each categories as cat}
|
|
<label class="flex items-center gap-1.5 text-xs">
|
|
<input type="checkbox" bind:checked={selectedCategories[cat.key]} />
|
|
{t(cat.label)}
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Secrets mode -->
|
|
<div class="mb-4">
|
|
<div class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</div>
|
|
<div class="flex flex-col gap-1.5">
|
|
<label class="flex items-center gap-1.5 text-xs">
|
|
<input type="radio" bind:group={exportSecrets} value="exclude" />
|
|
{t('backup.secretsExclude')}
|
|
</label>
|
|
<label class="flex items-center gap-1.5 text-xs">
|
|
<input type="radio" bind:group={exportSecrets} value="masked" />
|
|
{t('backup.secretsMasked')}
|
|
</label>
|
|
<label class="flex items-center gap-1.5 text-xs">
|
|
<input type="radio" bind:group={exportSecrets} value="include" />
|
|
{t('backup.secretsInclude')}
|
|
</label>
|
|
</div>
|
|
{#if exportSecrets === 'include'}
|
|
<div class="mt-2 p-2 rounded-md text-xs flex items-center gap-2"
|
|
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
|
<MdiIcon name="mdiAlert" size={14} />
|
|
{t('backup.secretsWarningExport')}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<Button onclick={doExport} disabled={exporting || noneSelected}>
|
|
{#if exporting}
|
|
<MdiIcon name="mdiLoading" size={14} />
|
|
{:else}
|
|
<MdiIcon name="mdiDownload" size={14} />
|
|
{/if}
|
|
{exporting ? t('common.loading') : t('backup.exportBtn')}
|
|
</Button>
|
|
</Card>
|
|
|
|
<!-- Import Section -->
|
|
<Card>
|
|
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
<MdiIcon name="mdiDatabaseImport" size={18} />
|
|
{t('backup.import')}
|
|
</h3>
|
|
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.importDescription')}</p>
|
|
|
|
<!-- File picker -->
|
|
<div class="mb-4">
|
|
<input type="file" accept=".json" onchange={handleFileSelect}
|
|
class="text-xs file:mr-2 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:cursor-pointer"
|
|
style="file:background: var(--color-muted); file:color: var(--color-foreground);" />
|
|
</div>
|
|
|
|
{#if importFile}
|
|
<!-- Validate -->
|
|
<div class="mb-4 flex items-center gap-2">
|
|
<Button variant="secondary" onclick={validateFile} disabled={validating}>
|
|
{#if validating}
|
|
<MdiIcon name="mdiLoading" size={14} />
|
|
{:else}
|
|
<MdiIcon name="mdiCheckCircleOutline" size={14} />
|
|
{/if}
|
|
{validating ? t('backup.validating') : t('backup.validateBtn')}
|
|
</Button>
|
|
</div>
|
|
|
|
{#if validationResult}
|
|
<div class="mb-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
|
<div class="flex items-center gap-2 mb-2 font-medium">
|
|
{#if validationResult.valid}
|
|
<span style="color: var(--color-success-fg, green);"><MdiIcon name="mdiCheckCircle" size={14} /></span>
|
|
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
|
|
{:else}
|
|
<MdiIcon name="mdiCloseCircle" size={14} />
|
|
<span style="color: var(--color-error-fg);">{t('backup.validationFailed')}</span>
|
|
{/if}
|
|
</div>
|
|
{#if Object.keys(validationResult.entity_counts || {}).length}
|
|
<div class="mb-2">
|
|
<span class="font-medium">{t('backup.entities')}:</span>
|
|
{#each Object.entries(validationResult.entity_counts) as [cat, count]}
|
|
<span class="inline-block mr-2">{cat}: {count}</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#each validationResult.warnings || [] as w}
|
|
<div class="flex items-start gap-1 mt-1" style="color: var(--color-warning-fg, orange);">
|
|
<MdiIcon name="mdiAlert" size={12} />
|
|
<span>{w}</span>
|
|
</div>
|
|
{/each}
|
|
{#each validationResult.errors || [] as e}
|
|
<div class="flex items-start gap-1 mt-1" style="color: var(--color-error-fg);">
|
|
<MdiIcon name="mdiAlertCircle" size={12} />
|
|
<span>{e}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Conflict mode -->
|
|
<div class="mb-4">
|
|
<div class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</div>
|
|
<div class="flex flex-col gap-1.5">
|
|
<label class="flex items-center gap-1.5 text-xs">
|
|
<input type="radio" bind:group={importConflict} value="skip" />
|
|
{t('backup.conflictSkip')}
|
|
</label>
|
|
<label class="flex items-center gap-1.5 text-xs">
|
|
<input type="radio" bind:group={importConflict} value="rename" />
|
|
{t('backup.conflictRename')}
|
|
</label>
|
|
<label class="flex items-center gap-1.5 text-xs">
|
|
<input type="radio" bind:group={importConflict} value="overwrite" />
|
|
{t('backup.conflictOverwrite')}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<Button onclick={doImport}
|
|
disabled={importing || !validationResult?.valid}>
|
|
{#if importing}
|
|
<MdiIcon name="mdiLoading" size={14} />
|
|
{:else}
|
|
<MdiIcon name="mdiUpload" size={14} />
|
|
{/if}
|
|
{importing ? t('backup.importing') : t('backup.importBtn')}
|
|
</Button>
|
|
|
|
{#if importResult}
|
|
<div class="mt-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
|
<div class="font-medium mb-1">{t('backup.importResults')}</div>
|
|
<div class="space-y-0.5">
|
|
<div>{t('backup.resultCreated')}: {importResult.created}</div>
|
|
<div>{t('backup.resultSkipped')}: {importResult.skipped}</div>
|
|
<div>{t('backup.resultOverwritten')}: {importResult.overwritten}</div>
|
|
{#if importResult.errors?.length}
|
|
<div style="color: var(--color-error-fg);">{t('backup.resultErrors')}: {importResult.errors.length}</div>
|
|
{#each importResult.errors as e}
|
|
<div class="ml-2" style="color: var(--color-error-fg);">{e}</div>
|
|
{/each}
|
|
{/if}
|
|
{#if importResult.warnings?.length}
|
|
{#each importResult.warnings as w}
|
|
<div class="ml-2" style="color: var(--color-warning-fg, orange);">{w}</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</Card>
|
|
|
|
<!-- Scheduled Backups Section -->
|
|
<Card>
|
|
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
<MdiIcon name="mdiClockOutline" size={18} />
|
|
{t('backup.scheduled')}
|
|
</h3>
|
|
|
|
<div class="space-y-3">
|
|
<label class="flex items-center gap-2 text-xs">
|
|
<input type="checkbox"
|
|
checked={scheduledSettings.backup_scheduled_enabled === 'true'}
|
|
onchange={() => scheduledSettings.backup_scheduled_enabled =
|
|
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'} />
|
|
<span class="font-medium">{t('backup.enableScheduled')}</span>
|
|
</label>
|
|
|
|
{#if scheduledSettings.backup_scheduled_enabled === 'true'}
|
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div>
|
|
<label for="backup-interval" class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
|
|
<select id="backup-interval" bind:value={scheduledSettings.backup_scheduled_interval_hours}
|
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
|
<option value="6">6 {t('backup.hours')}</option>
|
|
<option value="12">12 {t('backup.hours')}</option>
|
|
<option value="24">24 {t('backup.hours')}</option>
|
|
<option value="48">48 {t('backup.hours')}</option>
|
|
<option value="72">72 {t('backup.hours')}</option>
|
|
<option value="168">168 {t('backup.hours')} (7d)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="backup-secrets-mode" class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
|
|
<select id="backup-secrets-mode" bind:value={scheduledSettings.backup_secrets_mode}
|
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
|
<option value="exclude">{t('backup.secretsExclude')}</option>
|
|
<option value="masked">{t('backup.secretsMasked')}</option>
|
|
<option value="include">{t('backup.secretsInclude')}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="backup-retention" class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
|
|
<select id="backup-retention" bind:value={scheduledSettings.backup_retention_count}
|
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
|
<option value="3">3</option>
|
|
<option value="5">5</option>
|
|
<option value="10">10</option>
|
|
<option value="20">20</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
<Button onclick={saveSchedule} disabled={savingSchedule}>
|
|
{savingSchedule ? t('common.loading') : t('common.save')}
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
|
|
<!-- Saved Backup Files -->
|
|
<Card>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-sm font-semibold flex items-center gap-2">
|
|
<MdiIcon name="mdiFolder" size={18} />
|
|
{t('backup.savedFiles')}
|
|
</h3>
|
|
<div class="flex items-center gap-2">
|
|
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
|
|
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
|
|
</Button>
|
|
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
|
|
<MdiIcon name="mdiRefresh" size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if backupFiles.length === 0}
|
|
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('backup.noFiles')}</p>
|
|
{:else}
|
|
<div class="space-y-2">
|
|
{#each backupFiles as file}
|
|
<div class="flex items-center justify-between p-2 rounded-md border text-xs"
|
|
style="border-color: var(--color-border);">
|
|
<div class="flex items-center gap-2">
|
|
<MdiIcon name="mdiFileDocument" size={14} />
|
|
<span class="font-mono">{file.filename}</span>
|
|
<span style="color: var(--color-muted-foreground);">({formatSize(file.size)})</span>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<button onclick={() => downloadFile(file.filename)}
|
|
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('backup.download')}>
|
|
<MdiIcon name="mdiDownload" size={14} />
|
|
</button>
|
|
<button onclick={() => confirmDeleteFile = file.filename}
|
|
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('common.delete')}
|
|
style="color: var(--color-error-fg);">
|
|
<MdiIcon name="mdiDelete" size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</Card>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Confirm plaintext export -->
|
|
<ConfirmModal
|
|
open={confirmExportOpen}
|
|
title={t('backup.confirmExportTitle')}
|
|
message={t('backup.confirmExportMessage')}
|
|
confirmLabel={t('backup.exportBtn')}
|
|
confirmIcon="mdiDownload"
|
|
onconfirm={performExport}
|
|
oncancel={() => confirmExportOpen = false}
|
|
/>
|
|
|
|
<!-- Confirm import -->
|
|
<ConfirmModal
|
|
open={confirmImportOpen}
|
|
title={t('backup.confirmImportTitle')}
|
|
message={t('backup.confirmImportMessage')}
|
|
confirmLabel={t('backup.importBtn')}
|
|
confirmIcon="mdiUpload"
|
|
onconfirm={performImport}
|
|
oncancel={() => confirmImportOpen = false}
|
|
/>
|
|
|
|
<!-- Confirm delete file -->
|
|
<ConfirmModal
|
|
open={!!confirmDeleteFile}
|
|
title={t('common.delete')}
|
|
message={confirmDeleteFile}
|
|
onconfirm={() => deleteFile(confirmDeleteFile)}
|
|
oncancel={() => confirmDeleteFile = ''}
|
|
/>
|
|
|
|
<!-- Post-restore modal: Apply now or later -->
|
|
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
|
|
{#if postRestoreModalOpen && pending?.pending}
|
|
<div class="post-restore-backdrop"
|
|
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
|
|
onclick={() => postRestoreModalOpen = false}
|
|
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
|
|
role="presentation">
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
|
|
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
|
|
onclick={(e) => e.stopPropagation()}>
|
|
<div class="flex items-start gap-3 mb-4">
|
|
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
|
|
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
|
|
<MdiIcon name="mdiClockAlert" size={22} />
|
|
</div>
|
|
<div class="min-w-0">
|
|
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
|
|
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 justify-end flex-wrap">
|
|
<button onclick={() => postRestoreModalOpen = false}
|
|
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
|
{t('backup.applyLater')}
|
|
</button>
|
|
{#if pending.supervised}
|
|
<Button size="sm" onclick={() => { postRestoreModalOpen = false; applyAndRestart(); }}>
|
|
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Restarting overlay -->
|
|
{#if restartingOverlay}
|
|
<div role="alert" aria-live="assertive"
|
|
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
|
|
<div class="text-center p-6" style="color: var(--color-foreground);">
|
|
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
|
|
<MdiIcon name="mdiRestart" size={40} />
|
|
</div>
|
|
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
|
|
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.restart-spinner {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 40px;
|
|
height: 40px;
|
|
animation: restart-spin 1.2s linear infinite;
|
|
transform-origin: center center;
|
|
}
|
|
@keyframes restart-spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
</style>
|