mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 15:16:31 -04:00
@@ -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}
|
||||
Reference in New Issue
Block a user