diff --git a/frontend/src/lib/components/EntitySelect.svelte b/frontend/src/lib/components/EntitySelect.svelte index 9a3a0f3..2f96e54 100644 --- a/frontend/src/lib/components/EntitySelect.svelte +++ b/frontend/src/lib/components/EntitySelect.svelte @@ -20,7 +20,10 @@ noneLabel = '—', disabled = false, size = 'default', + open = $bindable(false), + showTrigger = true, onselect, + onclose, }: { items: EntityItem[]; value: string | number | null; @@ -29,10 +32,12 @@ noneLabel?: string; disabled?: boolean; size?: 'sm' | 'default'; + open?: boolean; + showTrigger?: boolean; onselect?: (value: string | number | null) => void; + onclose?: () => void; } = $props(); - let open = $state(false); let query = $state(''); let highlightIdx = $state(0); let inputEl = $state(); @@ -52,24 +57,37 @@ return [...result, ...matching]; }); + // Focus input whenever the palette transitions to open (covers both internal + // trigger clicks and external programmatic opening via bind:open). + let wasOpen = false; + $effect(() => { + if (open && !wasOpen) { + query = ''; + highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value))); + requestAnimationFrame(() => inputEl?.focus()); + } + wasOpen = open; + }); + function openPalette() { if (disabled) return; open = true; - query = ''; - highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value))); - requestAnimationFrame(() => inputEl?.focus()); } + // Called when the user dismisses the palette (overlay click or ESC). + // Selection uses its own quiet-close path so onclose stays a true "cancel" signal. function closePalette() { open = false; query = ''; + onclose?.(); } function selectItem(item: EntityItem) { if (item.disabled) return; value = item.value || null; onselect?.(value); - closePalette(); + open = false; + query = ''; } function handleKeydown(e: KeyboardEvent) { @@ -106,21 +124,23 @@ }); - - + + +{/if} {#if open} diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 6634386..7df0271 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -461,7 +461,13 @@ "receiverUpdated": "Receiver updated", "confirmDeleteReceiver": "Delete this receiver?", "receiverEnabled": "Receiver enabled", - "receiverDisabled": "Receiver disabled" + "receiverDisabled": "Receiver disabled", + "groupNoBot": "No bot linked", + "groupDirect": "Direct delivery", + "groupBotMissing": "Unknown bot", + "target": "target", + "targetsLower": "targets", + "openBot": "Open bot" }, "users": { "titleEmphasis": "& access", @@ -1383,6 +1389,30 @@ "applyLater": "Apply later", "restartNow": "Restart now", "restartingTitle": "Restarting backend…", - "restartingDescription": "The page will reload once the server is back online." + "restartingDescription": "The page will reload once the server is back online.", + "countLabel": "backups", + "scheduleOn": "Auto · every {h}h", + "scheduleOff": "Auto backup off", + "lastBackup": "Last {ago}", + "never": "no backups yet", + "totalSize": "{size} total", + "dropZone": "Drop a JSON backup here, or click to choose", + "dropZoneActive": "Release to load", + "changeFile": "Change file", + "catGroupIdentity": "Identity & Routing", + "catGroupNotif": "Notifications", + "catGroupCmd": "Commands", + "catGroupSystem": "System", + "stepCategories": "What to include", + "stepSecrets": "Secrets handling", + "stepDownload": "Download", + "stepFile": "Choose a file", + "stepValidate": "Validate contents", + "stepConflict": "On conflict", + "stepApply": "Apply", + "tagScheduled": "scheduled", + "tagManual": "manual", + "tagSecrets": "with secrets", + "validateFirst": "Validate the file first to enable import" } } \ No newline at end of file diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 5390c94..b787568 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -461,7 +461,13 @@ "receiverUpdated": "Получатель обновлён", "confirmDeleteReceiver": "Удалить этого получателя?", "receiverEnabled": "Получатель включён", - "receiverDisabled": "Получатель отключён" + "receiverDisabled": "Получатель отключён", + "groupNoBot": "Без привязки к боту", + "groupDirect": "Прямая доставка", + "groupBotMissing": "Неизвестный бот", + "target": "получатель", + "targetsLower": "получателей", + "openBot": "Открыть бота" }, "users": { "titleEmphasis": "и доступ", @@ -1383,6 +1389,30 @@ "applyLater": "Применить позже", "restartNow": "Перезапустить сейчас", "restartingTitle": "Перезапуск бэкенда…", - "restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен." + "restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен.", + "countLabel": "бэкапов", + "scheduleOn": "Авто · каждые {h}ч", + "scheduleOff": "Авто-бэкап выключен", + "lastBackup": "Последний {ago}", + "never": "ещё нет бэкапов", + "totalSize": "всего {size}", + "dropZone": "Перетащите JSON-бэкап сюда или нажмите для выбора", + "dropZoneActive": "Отпустите для загрузки", + "changeFile": "Сменить файл", + "catGroupIdentity": "Идентичность и маршрутизация", + "catGroupNotif": "Уведомления", + "catGroupCmd": "Команды", + "catGroupSystem": "Система", + "stepCategories": "Что включить", + "stepSecrets": "Обработка секретов", + "stepDownload": "Скачать", + "stepFile": "Выберите файл", + "stepValidate": "Проверить содержимое", + "stepConflict": "При конфликте", + "stepApply": "Применить", + "tagScheduled": "по расписанию", + "tagManual": "вручную", + "tagSecrets": "с секретами", + "validateFirst": "Сначала проверьте файл, чтобы включить импорт" } } \ No newline at end of file diff --git a/frontend/src/routes/settings/backup/+page.svelte b/frontend/src/routes/settings/backup/+page.svelte index 952deca..861fdd7 100644 --- a/frontend/src/routes/settings/backup/+page.svelte +++ b/frontend/src/routes/settings/backup/+page.svelte @@ -2,8 +2,6 @@ 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'; @@ -11,32 +9,55 @@ 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); + import BackupHero from './BackupHero.svelte'; + import PendingStrip from './PendingStrip.svelte'; + import ExportPanel from './ExportPanel.svelte'; + import ImportPanel from './ImportPanel.svelte'; + import ScheduleCassette from './ScheduleCassette.svelte'; + import BackupLedger from './BackupLedger.svelte'; - 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' }, + type SecretsMode = 'exclude' | 'masked' | 'include'; + type ConflictMode = 'skip' | 'rename' | 'overwrite'; + + interface BackupFile { + filename: string; + size: number; + created_at?: string | null; + } + + interface ScheduledSettings { + backup_scheduled_enabled: string; + backup_scheduled_interval_hours: string; + backup_secrets_mode: string; + backup_retention_count: string; + } + + interface PendingState { + pending: boolean; + uploaded_at?: string | null; + uploaded_by?: string | null; + conflict_mode?: string; + supervised?: boolean; + } + + const allCategories = [ + 'providers', 'telegram_bots', 'matrix_bots', 'email_bots', 'targets', + 'tracking_configs', 'template_configs', + 'command_configs', 'command_template_configs', + 'notification_trackers', 'command_trackers', + 'actions', 'app_settings', ]; + + // --- Export state --- + let exportSecrets = $state('exclude'); + let exporting = $state(false); let selectedCategories = $state>( - Object.fromEntries(categories.map(c => [c.key, true])) + Object.fromEntries(allCategories.map(k => [k, true])) ); // --- Import state --- let importFile: File | null = $state(null); - let importConflict = $state('skip'); + let importConflict = $state('skip'); let importing = $state(false); let validating = $state(false); let validationResult: any = $state(null); @@ -47,7 +68,7 @@ // --- Scheduled backup state --- let loaded = $state(false); let error = $state(''); - let scheduledSettings = $state({ + let scheduledSettings = $state({ backup_scheduled_enabled: 'false', backup_scheduled_interval_hours: '24', backup_secrets_mode: 'exclude', @@ -56,22 +77,22 @@ let savingSchedule = $state(false); // --- Backup files --- - let backupFiles = $state([]); + let backupFiles = $state([]); 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 pending = $state(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'), + api('/backup/scheduled'), + api('/backup/files'), + api('/backup/pending-restore'), ]); scheduledSettings = settings; backupFiles = files; @@ -84,7 +105,7 @@ } }); - async function cancelPending() { + async function cancelPending(): Promise { try { await api('/backup/pending-restore', { method: 'DELETE' }); snackSuccess(t('backup.pendingCancelled')); @@ -92,14 +113,13 @@ } catch (err: any) { snackError(err.message); } } - async function applyAndRestart() { + async function applyAndRestart(): Promise { 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 () => { + const poll = async (): Promise => { attempts += 1; try { const res = await fetch('/api/health'); @@ -117,7 +137,7 @@ } } - async function createManualBackup() { + async function createManualBackup(): Promise { creatingBackup = true; try { const mode = scheduledSettings.backup_secrets_mode || 'exclude'; @@ -132,7 +152,7 @@ } // --- Export --- - async function doExport() { + async function doExport(): Promise { if (exportSecrets === 'include') { confirmExportOpen = true; return; @@ -140,7 +160,7 @@ await performExport(); } - async function performExport() { + async function performExport(): Promise { confirmExportOpen = false; exporting = true; try { @@ -165,8 +185,14 @@ } } - // --- Validate --- - async function validateFile() { + // --- Validate / Import --- + function handleFileSelect(file: File | null): void { + importFile = file; + validationResult = null; + importResult = null; + } + + async function validateFile(): Promise { if (!importFile) return; validating = true; validationResult = null; @@ -183,12 +209,11 @@ } } - // --- Import --- - async function doImport() { + function doImport(): void { confirmImportOpen = true; } - async function performImport() { + async function performImport(): Promise { confirmImportOpen = false; if (!importFile) return; importing = true; @@ -213,10 +238,10 @@ } // --- Scheduled settings --- - async function saveSchedule() { + async function saveSchedule(): Promise { savingSchedule = true; try { - scheduledSettings = await api('/backup/scheduled', { + scheduledSettings = await api('/backup/scheduled', { method: 'PUT', body: JSON.stringify(scheduledSettings), }); @@ -229,10 +254,10 @@ } // --- File management --- - async function refreshFiles() { + async function refreshFiles(): Promise { loadingFiles = true; try { - backupFiles = await api('/backup/files'); + backupFiles = await api('/backup/files'); } catch (err: any) { snackError(err.message); } finally { @@ -240,7 +265,7 @@ } } - async function downloadFile(filename: string) { + async function downloadFile(filename: string): Promise { try { const data = await api(`/backup/files/${filename}`); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); @@ -255,7 +280,7 @@ } } - async function deleteFile(filename: string) { + async function deleteFile(filename: string): Promise { try { await api(`/backup/files/${filename}`, { method: 'DELETE' }); snackSuccess(t('backup.fileDeleted')); @@ -265,355 +290,61 @@ 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; - } - } - + {#if !loaded} {:else} - {#if pending?.pending} -
- - - -
-
{t('backup.pendingTitle')}
-
- {t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')} -
-
-
- {#if pending.supervised} - - {/if} - -
+ + +
+
+ selectedCategories = next} + onSecretsChange={(next) => exportSecrets = next} + onExport={doExport} + /> + importConflict = mode} + onValidate={validateFile} + onImport={doImport} + />
- {/if} -
+ scheduledSettings.backup_scheduled_enabled = + scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'} + onSave={saveSchedule} + /> - - -

- - {t('backup.export')} -

-

{t('backup.exportDescription')}

- - -
-
- {t('backup.categories')} - -
-
- {#each categories as cat} - - {/each} -
-
- - -
-
{t('backup.secretsMode')}
-
- - - -
- {#if exportSecrets === 'include'} -
- - {t('backup.secretsWarningExport')} -
- {/if} -
- - -
- - - -

- - {t('backup.import')} -

-

{t('backup.importDescription')}

- - -
- -
- - {#if importFile} - -
- -
- - {#if validationResult} -
-
- {#if validationResult.valid} - - {t('backup.validationPassed')} - {:else} - - {t('backup.validationFailed')} - {/if} -
- {#if Object.keys(validationResult.entity_counts || {}).length} -
- {t('backup.entities')}: - {#each Object.entries(validationResult.entity_counts) as [cat, count]} - {cat}: {count} - {/each} -
- {/if} - {#each validationResult.warnings || [] as w} -
- - {w} -
- {/each} - {#each validationResult.errors || [] as e} -
- - {e} -
- {/each} -
- {/if} - - -
-
{t('backup.conflictMode')}
-
- - - -
-
- - - - {#if importResult} -
-
{t('backup.importResults')}
-
-
{t('backup.resultCreated')}: {importResult.created}
-
{t('backup.resultSkipped')}: {importResult.skipped}
-
{t('backup.resultOverwritten')}: {importResult.overwritten}
- {#if importResult.errors?.length} -
{t('backup.resultErrors')}: {importResult.errors.length}
- {#each importResult.errors as e} -
{e}
- {/each} - {/if} - {#if importResult.warnings?.length} - {#each importResult.warnings as w} -
{w}
- {/each} - {/if} -
-
- {/if} - {/if} -
- - - -

- - {t('backup.scheduled')} -

- -
- - - {#if scheduledSettings.backup_scheduled_enabled === 'true'} -
-
- - -
-
- - -
-
- - -
-
- {/if} -
- -
- -
-
- - - -
-

- - {t('backup.savedFiles')} -

-
- - -
-
- - {#if backupFiles.length === 0} -

{t('backup.noFiles')}

- {:else} -
- {#each backupFiles as file} -
-
- - {file.filename} - ({formatSize(file.size)}) -
-
- - -
-
- {/each} -
- {/if} -
+ confirmDeleteFile = filename} + />
{/if} @@ -652,27 +383,25 @@ { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} /> {#if postRestoreModalOpen && pending?.pending}
postRestoreModalOpen = false} onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }} role="presentation">