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.
91 lines
2.8 KiB
Svelte
91 lines
2.8 KiB
Svelte
<script lang="ts">
|
|
import { t } from '$lib/i18n';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
|
|
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
|
|
|
|
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 Props {
|
|
files: BackupFile[];
|
|
scheduled: ScheduledSettings;
|
|
pending: { pending: boolean } | null;
|
|
}
|
|
|
|
let { files, scheduled, pending }: Props = $props();
|
|
|
|
function relativeTime(iso: string | null | undefined): string {
|
|
if (!iso) return '';
|
|
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
|
if (isNaN(date.getTime())) return '';
|
|
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
|
if (diffSec < 60) return t('dashboard.justNow');
|
|
const min = Math.floor(diffSec / 60);
|
|
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
|
const hr = Math.floor(min / 60);
|
|
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
|
const day = Math.floor(hr / 24);
|
|
return t('dashboard.daysAgo').replace('{n}', String(day));
|
|
}
|
|
|
|
function latestCreatedAt(list: BackupFile[]): string | null {
|
|
const stamps = list
|
|
.map(f => f.created_at)
|
|
.filter((s): s is string => !!s)
|
|
.sort();
|
|
return stamps.length ? stamps[stamps.length - 1] : null;
|
|
}
|
|
|
|
function ageHours(iso: string | null): number {
|
|
if (!iso) return Infinity;
|
|
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
|
if (isNaN(date.getTime())) return Infinity;
|
|
return (Date.now() - date.getTime()) / 3_600_000;
|
|
}
|
|
|
|
const pills = $derived.by<Array<{ label: string; tone?: Tone }>>(() => {
|
|
const out: Array<{ label: string; tone?: Tone }> = [];
|
|
if (pending?.pending) {
|
|
out.push({ label: t('backup.restorePrepared'), tone: 'coral' });
|
|
}
|
|
if (scheduled.backup_scheduled_enabled === 'true') {
|
|
out.push({
|
|
label: t('backup.scheduleOn').replace('{h}', scheduled.backup_scheduled_interval_hours || '24'),
|
|
tone: 'mint',
|
|
});
|
|
} else {
|
|
out.push({ label: t('backup.scheduleOff') });
|
|
}
|
|
const latest = latestCreatedAt(files);
|
|
if (latest) {
|
|
const hours = ageHours(latest);
|
|
const tone: Tone = hours < 48 ? 'mint' : hours < 24 * 7 ? 'citrus' : 'coral';
|
|
out.push({ label: t('backup.lastBackup').replace('{ago}', relativeTime(latest)), tone });
|
|
} else {
|
|
out.push({ label: t('backup.never'), tone: 'citrus' });
|
|
}
|
|
return out;
|
|
});
|
|
</script>
|
|
|
|
<PageHeader
|
|
title={t('backup.title')}
|
|
emphasis={t('backup.titleEmphasis')}
|
|
description={t('backup.description')}
|
|
crumb={t('crumbs.systemMaintenance')}
|
|
count={files.length}
|
|
countLabel={t('backup.countLabel')}
|
|
{pills}
|
|
/>
|