feat: auto select latest db backup

Signed-off-by: izzy <me@insrt.uk>
This commit is contained in:
izzy
2026-05-18 12:44:01 +01:00
parent 3465ed5c6b
commit 64adfa6cc3
6 changed files with 239 additions and 112 deletions
+5
View File
@@ -1466,6 +1466,7 @@
"maintenance_end_error": "Failed to end maintenance mode.",
"maintenance_logged_in_as": "Currently logged in as {user}",
"maintenance_restore_from_backup": "Restore From Backup",
"maintenance_restore_latest_backup_description": "We'll restore your database from the most recent backup. You can also pick a different one.",
"maintenance_restore_library": "Restore Your Library",
"maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
"maintenance_restore_library_description": "Restoring Database",
@@ -1478,6 +1479,10 @@
"maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
"maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
"maintenance_restore_library_loading": "Loading integrity checks and heuristics…",
"maintenance_restore_loading_backups": "Loading backups…",
"maintenance_restore_no_backups": "There are no database backups.",
"maintenance_restore_select_another": "Select another backup",
"maintenance_restore_upload_backup": "Upload a backup",
"maintenance_task_backup": "Creating a backup of the existing database…",
"maintenance_task_migrations": "Running database migrations…",
"maintenance_task_restore": "Restoring the chosen backup…",
@@ -0,0 +1,119 @@
<script lang="ts">
import { BackupFileStatus } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { Card, CardBody, HStack, Icon, Stack, Text } from '@immich/ui';
import { mdiAlertCircle, mdiCheckCircle, mdiDatabaseRefreshOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
filename: string;
filesize: number;
expectedVersion: string;
timezone?: string;
showFullDate?: boolean;
class?: string;
actions?: Snippet;
};
const {
filename,
filesize,
expectedVersion,
timezone,
showFullDate = false,
class: className,
actions,
}: Props = $props();
const filesizeText = $derived(getBytesWithUnit(filesize, 1));
const backupDateTime = $derived.by(() => {
const dateMatch = filename.match(/\d+T\d+/);
if (dateMatch) {
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }).toLocal();
}
return null;
});
const dateDisplay = $derived(
backupDateTime?.toLocaleString(showFullDate ? DateTime.DATETIME_MED : DateTime.TIME_SIMPLE),
);
const relativeTime = $derived(backupDateTime?.toRelative({ locale: $locale }));
const version = $derived(filename.match(/-v(.*)-/)?.[1]);
const status = $derived.by(() => {
if (!version) {
return BackupFileStatus.UnknownVersion;
}
if (version !== expectedVersion) {
return BackupFileStatus.DifferentVersion;
}
return BackupFileStatus.OK;
});
</script>
<Card class={`dark:bg-dark-900 ${className ?? ''}`}>
<CardBody class="px-6 pt-3 pb-4">
<Stack gap={3} class="min-w-0 grow">
<div class={actions ? 'flex items-center justify-between gap-3' : ''}>
<HStack gap={2} class="min-w-0">
{#if status === BackupFileStatus.OK}
<Icon icon={mdiCheckCircle} size="18" class="text-success" />
{:else if status === BackupFileStatus.DifferentVersion}
<Icon icon={mdiAlertCircle} size="18" class="text-warning" />
{:else}
<Icon icon={mdiAlertCircle} size="18" class="text-danger" />
{/if}
{#if dateDisplay}
<Text class="font-medium" size="small">{dateDisplay}</Text>
{:else}
<Text class="font-medium" size="small">{$t('unknown_date')}</Text>
{/if}
{#if relativeTime}
<div class="flex items-center gap-2">
<div class="size-1 bg-light-500"></div>
<Text size="tiny" color="muted">{relativeTime}</Text>
</div>
{/if}
</HStack>
{#if actions}
<HStack gap={1}>
{@render actions()}
</HStack>
{/if}
</div>
<HStack>
<Icon icon={mdiDatabaseRefreshOutline} size="16" color="gray" />
<Text size="small" class="font-mono break-all">{filename}</Text>
</HStack>
{#if status === BackupFileStatus.UnknownVersion}
<Text size="small" color="danger">
{$t('admin.maintenance_restore_backup_unknown_version')}
</Text>
{:else if status === BackupFileStatus.DifferentVersion}
<Text size="small" color="warning">
{$t('admin.maintenance_restore_backup_different_version')}
</Text>
{/if}
<HStack gap={8}>
<div class="flex gap-1">
<Text size="tiny" color="muted">{$t('version')}:</Text>
<Text size="tiny" fontWeight="medium">{version ? `v${version}` : $t('unknown')}</Text>
</div>
<div class="flex gap-1">
<Text size="tiny" color="muted">{$t('size')}:</Text>
<Text size="tiny" fontWeight="medium">{filesizeText[0]} {filesizeText[1]}</Text>
</div>
</HStack>
</Stack>
</CardBody>
</Card>
@@ -1,12 +1,8 @@
<script lang="ts">
import MaintenanceBackupCard from '$lib/components/maintenance/MaintenanceBackupCard.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { BackupFileStatus } from '$lib/constants';
import { getDatabaseBackupActions, handleRestoreDatabaseBackup } from '$lib/services/database-backups.service';
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { Button, Card, CardBody, ContextMenuButton, HStack, Icon, Stack, Text } from '@immich/ui';
import { mdiAlertCircle, mdiCheckCircle, mdiDatabaseRefreshOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { Button, ContextMenuButton } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
@@ -18,31 +14,6 @@
const { filename, filesize, expectedVersion, timezone }: Props = $props();
const filesizeText = $derived(getBytesWithUnit(filesize, 1));
const backupDateTime = $derived.by(() => {
const dateMatch = filename.match(/\d+T\d+/);
if (dateMatch) {
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }).toLocal();
}
return null;
});
const timeDisplay = $derived(backupDateTime?.toLocaleString(DateTime.TIME_SIMPLE));
const relativeTime = $derived(backupDateTime?.toRelative({ locale: $locale }));
const version = $derived(filename.match(/-v(.*)-/)?.[1]);
const status = $derived.by(() => {
if (!version) {
return BackupFileStatus.UnknownVersion;
}
if (version !== expectedVersion) {
return BackupFileStatus.DifferentVersion;
}
return BackupFileStatus.OK;
});
const { Download, Delete } = $derived(getDatabaseBackupActions($t, filename));
let isDeleting = $state(false);
@@ -56,71 +27,16 @@
<OnEvents {onBackupDeleteStatus} />
<Card class="dark:bg-dark-900">
<CardBody class="px-6 pt-3 pb-4">
<Stack gap={3} class="min-w-0 grow">
<div class="flex items-center justify-between gap-3">
<HStack gap={2} class="min-w-0">
{#if status === BackupFileStatus.OK}
<Icon icon={mdiCheckCircle} size="18" class="text-success" />
{:else if status === BackupFileStatus.DifferentVersion}
<Icon icon={mdiAlertCircle} size="18" class="text-warning" />
{:else}
<Icon icon={mdiAlertCircle} size="18" class="text-danger" />
{/if}
{#if timeDisplay}
<Text class="font-medium" size="small">{timeDisplay}</Text>
{:else}
<Text class="font-medium" size="small">{$t('unknown_date')}</Text>
{/if}
{#if relativeTime}
<div class="flex items-center gap-2">
<div class="size-1 bg-light-500"></div>
<Text size="tiny" color="muted">{relativeTime}</Text>
</div>
{/if}
</HStack>
<HStack gap={1}>
<Button size="small" onclick={() => handleRestoreDatabaseBackup(filename)} disabled={isDeleting}
>{$t('restore')}</Button
>
<ContextMenuButton
disabled={isDeleting}
position="top-right"
aria-label={$t('open')}
items={[Download, Delete]}
/>
</HStack>
</div>
<HStack>
<Icon icon={mdiDatabaseRefreshOutline} size="16" color="gray" />
<Text size="small" class="font-mono break-all">{filename}</Text>
</HStack>
{#if status === BackupFileStatus.UnknownVersion}
<Text size="small" color="danger">
{$t('admin.maintenance_restore_backup_unknown_version')}
</Text>
{:else if status === BackupFileStatus.DifferentVersion}
<Text size="small" color="warning">
{$t('admin.maintenance_restore_backup_different_version')}
</Text>
{/if}
<HStack gap={8}>
<div class="flex gap-1">
<Text size="tiny" color="muted">{$t('version')}:</Text>
<Text size="tiny" fontWeight="medium">{version ? `v${version}` : $t('unknown')}</Text>
</div>
<div class="flex gap-1">
<Text size="tiny" color="muted">{$t('size')}:</Text>
<Text size="tiny" fontWeight="medium">{filesizeText[0]} {filesizeText[1]}</Text>
</div>
</HStack>
</Stack>
</CardBody>
</Card>
<MaintenanceBackupCard {filename} {filesize} {expectedVersion} {timezone}>
{#snippet actions()}
<Button size="small" onclick={() => handleRestoreDatabaseBackup(filename)} disabled={isDeleting}>
{$t('restore')}
</Button>
<ContextMenuButton
disabled={isDeleting}
position="top-right"
aria-label={$t('open')}
items={[Download, Delete]}
/>
{/snippet}
</MaintenanceBackupCard>
@@ -1,12 +1,15 @@
import {
deleteDatabaseBackup,
getBaseUrl,
listDatabaseBackups,
MaintenanceAction,
setMaintenanceMode,
type DatabaseBackupDto,
type DatabaseBackupUploadDto,
} from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui';
import { mdiDownload, mdiTrashCanOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import type { MessageFormatter } from 'svelte-i18n';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { uploadRequest } from '$lib/utils';
@@ -14,6 +17,26 @@ import { openFilePicker } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
export const loadDatabaseBackups = async (): Promise<DatabaseBackupDto[]> => {
const { backups } = await listDatabaseBackups();
return backups;
};
const getBackupTimestamp = (backup: DatabaseBackupDto): number => {
const dateMatch = backup.filename.match(/\d+T\d+/);
if (!dateMatch) {
return 0;
}
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: backup.timezone }).toMillis();
};
export const getLatestBackup = (backups: DatabaseBackupDto[]): DatabaseBackupDto | undefined => {
if (backups.length === 0) {
return undefined;
}
return [...backups].sort((a, b) => getBackupTimestamp(b) - getBackupTimestamp(a))[0];
};
export const getDatabaseBackupActions = ($t: MessageFormatter, filename: string) => {
const Download: ActionItem = {
title: $t('download'),
@@ -1,4 +1,5 @@
<script lang="ts">
import RestoreFlowAutoSelectBackup from './RestoreFlowAutoSelectBackup.svelte';
import RestoreFlowDetectInstall from './RestoreFlowDetectInstall.svelte';
import RestoreFlowIntro from './RestoreFlowIntro.svelte';
import RestoreFlowSelectBackup from './RestoreFlowSelectBackup.svelte';
@@ -11,26 +12,27 @@
const { end, expectedVersion }: Props = $props();
let stage = $state(localStorage.getItem('restoring-yucca') ? 1 : 0);
let stage: 'overview' | 'yucca' | 'detect-install' | 'auto-select-backup' | 'select-backup' = $state(
localStorage.getItem('restoring-yucca') ? 'yucca' : 'overview',
);
$effect(() => {
if (stage === 1) {
if (stage === 'yucca') {
localStorage.setItem('restoring-yucca', '1');
} else {
localStorage.removeItem('restoring-yucca');
}
});
const next = () => stage++;
const previous = () => stage--;
</script>
{#if stage === 0}
<RestoreFlowIntro flowToYucca={() => (stage = 1)} flowToDatabase={() => (stage = 2)} {end} />
{:else if stage === 1}
<ImmichOnboardingRestoreFlow onExit={previous} onFinish={() => stage++} />
{:else if stage === 2}
<RestoreFlowDetectInstall {next} previous={() => (stage = 0)} />
{:else}
<RestoreFlowSelectBackup {previous} {end} {expectedVersion} />
{#if stage === 'overview'}
<RestoreFlowIntro flowToYucca={() => (stage = 'yucca')} flowToDatabase={() => (stage = 'detect-install')} {end} />
{:else if stage === 'yucca'}
<ImmichOnboardingRestoreFlow onExit={() => (stage = 'overview')} onFinish={() => (stage = 'auto-select-backup')} />
{:else if stage === 'detect-install'}
<RestoreFlowDetectInstall next={() => (stage = 'select-backup')} previous={() => (stage = 'overview')} />
{:else if stage === 'select-backup'}
<RestoreFlowSelectBackup previous={() => (stage = 'detect-install')} {end} {expectedVersion} />
{:else if stage === 'auto-select-backup'}
<RestoreFlowAutoSelectBackup selectAnother={() => (stage = 'select-backup')} {end} {expectedVersion} />
{/if}
@@ -0,0 +1,62 @@
<script lang="ts">
import MaintenanceBackupCard from '$lib/components/maintenance/MaintenanceBackupCard.svelte';
import {
getLatestBackup,
handleRestoreDatabaseBackup,
loadDatabaseBackups,
} from '$lib/services/database-backups.service';
import type { DatabaseBackupDto } from '@immich/sdk';
import { Alert, Button, Heading, HStack, Icon, Text } from '@immich/ui';
import { mdiArrowRight, mdiRefresh } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
selectAnother: () => void;
end: () => void;
expectedVersion: string;
};
const { selectAnother, end, expectedVersion }: Props = $props();
let backups: DatabaseBackupDto[] | undefined = $state();
onMount(async () => {
backups = await loadDatabaseBackups();
});
const latest = $derived(backups ? getLatestBackup(backups) : undefined);
</script>
<Heading size="large" color="primary" tag="h1">{$t('maintenance_restore_from_backup')}</Heading>
{#if backups === undefined}
<HStack>
<Icon icon={mdiRefresh} color="rgb(var(--immich-ui-primary))" />
<Text>{$t('maintenance_restore_loading_backups')}</Text>
</HStack>
{:else if latest === undefined}
<Alert color="warning" title={$t('maintenance_restore_no_backups')} />
<HStack>
<Button onclick={end} variant="ghost">{$t('cancel')}</Button>
<Button onclick={selectAnother}>{$t('maintenance_restore_upload_backup')}</Button>
</HStack>
{:else}
<Text>{$t('maintenance_restore_latest_backup_description')}</Text>
<MaintenanceBackupCard
filename={latest.filename}
filesize={latest.filesize}
timezone={latest.timezone}
{expectedVersion}
showFullDate
class="text-left"
/>
<HStack>
<Button onclick={end} variant="ghost">{$t('cancel')}</Button>
<Button onclick={selectAnother} variant="ghost">{$t('maintenance_restore_select_another')}</Button>
<Button onclick={() => handleRestoreDatabaseBackup(latest.filename)} trailingIcon={mdiArrowRight}>
{$t('continue')}
</Button>
</HStack>
{/if}