a666bad0c4
Targets page: collapse targets under a per-bot header (BotGroupHeader) with a count chip and an "Open bot" cross-link. Receivers are hidden by default and expand per group; non-bot types fall back to a "Direct delivery" group. Telegram "Add receiver" now opens the EntitySelect chat palette directly instead of an inline form — EntitySelect grew a bindable `open` flag, `showTrigger`, and an `onclose` cancel signal. Backup settings page: split the monolithic +page into focused panels (BackupHero, BackupLedger, ExportPanel, ImportPanel, PendingStrip, ScheduleCassette) and introduce a stepwise export/import flow with category groups, secrets handling, conflict policy, and validation gating. New i18n keys in both locales cover the bot grouping labels and the backup step copy.
578 lines
15 KiB
Svelte
578 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { api, fetchAuth } from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
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';
|
|
|
|
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';
|
|
|
|
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<SecretsMode>('exclude');
|
|
let exporting = $state(false);
|
|
let selectedCategories = $state<Record<string, boolean>>(
|
|
Object.fromEntries(allCategories.map(k => [k, true]))
|
|
);
|
|
|
|
// --- Import state ---
|
|
let importFile: File | null = $state(null);
|
|
let importConflict = $state<ConflictMode>('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<ScheduledSettings>({
|
|
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<BackupFile[]>([]);
|
|
let loadingFiles = $state(false);
|
|
let confirmDeleteFile = $state('');
|
|
let creatingBackup = $state(false);
|
|
|
|
// --- Pending restore state ---
|
|
let pending = $state<PendingState | null>(null);
|
|
let postRestoreModalOpen = $state(false);
|
|
let restartingOverlay = $state(false);
|
|
|
|
onMount(async () => {
|
|
try {
|
|
const [settings, files, p] = await Promise.all([
|
|
api<ScheduledSettings>('/backup/scheduled'),
|
|
api<BackupFile[]>('/backup/files'),
|
|
api<PendingState>('/backup/pending-restore'),
|
|
]);
|
|
scheduledSettings = settings;
|
|
backupFiles = files;
|
|
pending = p;
|
|
} catch (err: any) {
|
|
error = err.message;
|
|
snackError(err.message);
|
|
} finally {
|
|
loaded = true;
|
|
}
|
|
});
|
|
|
|
async function cancelPending(): Promise<void> {
|
|
try {
|
|
await api('/backup/pending-restore', { method: 'DELETE' });
|
|
snackSuccess(t('backup.pendingCancelled'));
|
|
pending = null;
|
|
} catch (err: any) { snackError(err.message); }
|
|
}
|
|
|
|
async function applyAndRestart(): Promise<void> {
|
|
try {
|
|
await api('/backup/apply-restart', { method: 'POST' });
|
|
restartingOverlay = true;
|
|
const startedAt = Date.now();
|
|
let attempts = 0;
|
|
const poll = async (): Promise<void> => {
|
|
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(): Promise<void> {
|
|
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(): Promise<void> {
|
|
if (exportSecrets === 'include') {
|
|
confirmExportOpen = true;
|
|
return;
|
|
}
|
|
await performExport();
|
|
}
|
|
|
|
async function performExport(): Promise<void> {
|
|
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 / Import ---
|
|
function handleFileSelect(file: File | null): void {
|
|
importFile = file;
|
|
validationResult = null;
|
|
importResult = null;
|
|
}
|
|
|
|
async function validateFile(): Promise<void> {
|
|
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;
|
|
}
|
|
}
|
|
|
|
function doImport(): void {
|
|
confirmImportOpen = true;
|
|
}
|
|
|
|
async function performImport(): Promise<void> {
|
|
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(): Promise<void> {
|
|
savingSchedule = true;
|
|
try {
|
|
scheduledSettings = await api<ScheduledSettings>('/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(): Promise<void> {
|
|
loadingFiles = true;
|
|
try {
|
|
backupFiles = await api<BackupFile[]>('/backup/files');
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
} finally {
|
|
loadingFiles = false;
|
|
}
|
|
}
|
|
|
|
async function downloadFile(filename: string): Promise<void> {
|
|
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): Promise<void> {
|
|
try {
|
|
await api(`/backup/files/${filename}`, { method: 'DELETE' });
|
|
snackSuccess(t('backup.fileDeleted'));
|
|
confirmDeleteFile = '';
|
|
await refreshFiles();
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<BackupHero files={backupFiles} scheduled={scheduledSettings} {pending} />
|
|
|
|
{#if !loaded}
|
|
<Loading />
|
|
{:else}
|
|
<ErrorBanner message={error} />
|
|
|
|
<PendingStrip {pending} onApply={applyAndRestart} onCancel={cancelPending} />
|
|
|
|
<div class="backup-page stagger-children">
|
|
<div class="action-deck">
|
|
<ExportPanel
|
|
{selectedCategories}
|
|
{exportSecrets}
|
|
{exporting}
|
|
onCategoriesChange={(next) => selectedCategories = next}
|
|
onSecretsChange={(next) => exportSecrets = next}
|
|
onExport={doExport}
|
|
/>
|
|
<ImportPanel
|
|
{importFile}
|
|
{importConflict}
|
|
{validating}
|
|
{validationResult}
|
|
{importing}
|
|
{importResult}
|
|
onFileSelect={handleFileSelect}
|
|
onConflictChange={(mode) => importConflict = mode}
|
|
onValidate={validateFile}
|
|
onImport={doImport}
|
|
/>
|
|
</div>
|
|
|
|
<ScheduleCassette
|
|
enabled={scheduledSettings.backup_scheduled_enabled === 'true'}
|
|
bind:intervalHours={scheduledSettings.backup_scheduled_interval_hours}
|
|
bind:secretsMode={scheduledSettings.backup_secrets_mode}
|
|
bind:retentionCount={scheduledSettings.backup_retention_count}
|
|
saving={savingSchedule}
|
|
onToggle={() => scheduledSettings.backup_scheduled_enabled =
|
|
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'}
|
|
onSave={saveSchedule}
|
|
/>
|
|
|
|
<BackupLedger
|
|
files={backupFiles}
|
|
loading={loadingFiles}
|
|
creating={creatingBackup}
|
|
onCreate={createManualBackup}
|
|
onRefresh={refreshFiles}
|
|
onDownload={downloadFile}
|
|
onDelete={(filename) => confirmDeleteFile = filename}
|
|
/>
|
|
</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"
|
|
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"
|
|
class="post-restore-card"
|
|
onclick={(e) => e.stopPropagation()}>
|
|
<div class="post-restore-head">
|
|
<div class="post-restore-icon">
|
|
<MdiIcon name="mdiClockAlert" size={22} />
|
|
</div>
|
|
<div class="post-restore-text">
|
|
<h3 id="post-restore-title">{t('backup.restorePrepared')}</h3>
|
|
<p>{t('backup.restoreApplyPrompt')}</p>
|
|
</div>
|
|
</div>
|
|
<div class="post-restore-actions">
|
|
<button class="post-restore-later" type="button"
|
|
onclick={() => postRestoreModalOpen = false}>
|
|
{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 class="restart-overlay" role="alert" aria-live="assertive">
|
|
<div class="restart-card">
|
|
<div class="restart-spinner">
|
|
<MdiIcon name="mdiRestart" size={40} />
|
|
</div>
|
|
<p class="restart-title">{t('backup.restartingTitle')}</p>
|
|
<p class="restart-sub">{t('backup.restartingDescription')}</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.backup-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.action-deck {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 1.25rem;
|
|
align-items: stretch;
|
|
}
|
|
@media (min-width: 960px) {
|
|
.action-deck { grid-template-columns: 1fr 1fr; }
|
|
}
|
|
|
|
/* Post-restore modal */
|
|
.post-restore-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 9999;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
backdrop-filter: blur(4px);
|
|
-webkit-backdrop-filter: blur(4px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
}
|
|
.post-restore-card {
|
|
background: var(--color-glass-elev);
|
|
backdrop-filter: blur(28px) saturate(160%);
|
|
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
|
border: 1px solid var(--color-rule-strong);
|
|
border-radius: 22px;
|
|
padding: 1.5rem;
|
|
max-width: 440px;
|
|
width: 100%;
|
|
box-shadow: 0 30px 70px -16px rgba(0, 0, 0, 0.6);
|
|
}
|
|
.post-restore-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.85rem;
|
|
margin-bottom: 1.1rem;
|
|
}
|
|
.post-restore-icon {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 42px; height: 42px;
|
|
border-radius: 50%;
|
|
background: var(--color-warning-bg);
|
|
color: var(--color-warning-fg);
|
|
flex-shrink: 0;
|
|
}
|
|
.post-restore-text { min-width: 0; }
|
|
.post-restore-text h3 {
|
|
font-family: var(--font-display);
|
|
font-style: italic;
|
|
font-weight: 500;
|
|
font-size: 1.15rem;
|
|
margin: 0 0 0.25rem;
|
|
letter-spacing: -0.015em;
|
|
}
|
|
.post-restore-text p {
|
|
font-size: 0.82rem;
|
|
color: var(--color-muted-foreground);
|
|
margin: 0;
|
|
line-height: 1.45;
|
|
word-wrap: break-word;
|
|
}
|
|
.post-restore-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
flex-wrap: wrap;
|
|
}
|
|
.post-restore-later {
|
|
padding: 0 0.95rem;
|
|
height: 34px;
|
|
border-radius: 12px;
|
|
background: transparent;
|
|
border: 1px solid var(--color-border);
|
|
color: var(--color-muted-foreground);
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
font-size: 0.82rem;
|
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
}
|
|
.post-restore-later:hover {
|
|
background: var(--color-glass-strong);
|
|
color: var(--color-foreground);
|
|
border-color: var(--color-rule-strong);
|
|
}
|
|
|
|
/* Restarting overlay */
|
|
.restart-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 9999;
|
|
background: rgba(0, 0, 0, 0.72);
|
|
backdrop-filter: blur(6px);
|
|
-webkit-backdrop-filter: blur(6px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
}
|
|
.restart-card {
|
|
text-align: center;
|
|
padding: 1.6rem 2rem;
|
|
color: var(--color-foreground);
|
|
}
|
|
.restart-spinner {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 40px;
|
|
height: 40px;
|
|
margin-bottom: 0.85rem;
|
|
color: var(--color-primary);
|
|
animation: restart-spin 1.2s linear infinite;
|
|
transform-origin: center center;
|
|
}
|
|
.restart-title {
|
|
font-family: var(--font-display);
|
|
font-style: italic;
|
|
font-size: 1.2rem;
|
|
font-weight: 500;
|
|
margin: 0;
|
|
letter-spacing: -0.015em;
|
|
}
|
|
.restart-sub {
|
|
font-size: 0.8rem;
|
|
color: var(--color-muted-foreground);
|
|
margin: 0.4rem 0 0;
|
|
}
|
|
@keyframes restart-spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.restart-spinner { animation: none !important; }
|
|
}
|
|
</style>
|