Files
notify-bridge/frontend/src/routes/settings/backup/+page.svelte
T
alexei.dolgolyov 711f218622 fix(redesign): a11y, mobile, perf polish for production push
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
2026-04-25 14:41:12 +03:00

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>