fix: timestamp handling for database backup in Web UI (#27359)

* Fix #26502: Fix timestamp handling for database backup in Web UI

Frontend parsed backup timestamps as UTC, but they were in the
server's local timezone, causing wrong relative times.

Add `timezone` field to DatabaseBackupDto to expose server timezone.
Update frontend to parse timestamps using this timezone.
Convert timestamps to user's local timezone before rendering.
Fallback to browser timezone if server timezone is missing.

Ensures correct relative time display in Web UI.

* fix: regenerate open-api types and remove custom backup type

- Ran `make open-api` to update types based on backend changes
- Removed custom BackupWithTimezone type in MaintenanceBackupsList
- Updated timezone props to use the newly generated native type

* fix: simplify timezone handling for database backups

- Updated DatabaseBackupDto to make timezone a required property
- Removed manual DateTime.local().zoneName fallbacks
- Cleaned up type casts after regenerating OpenAPI types

* fix: Add missing newline at end of spec file
This commit is contained in:
Afonso Mendonça Ribeiro
2026-04-06 16:27:48 +01:00
committed by GitHub
parent fbe631fe91
commit 95c1f0efeb
8 changed files with 80 additions and 8 deletions

View File

@@ -15,30 +15,36 @@ class DatabaseBackupDto {
DatabaseBackupDto({
required this.filename,
required this.filesize,
required this.timezone,
});
String filename;
num filesize;
String timezone;
@override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDto &&
other.filename == filename &&
other.filesize == filesize;
other.filesize == filesize &&
other.timezone == timezone;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filename.hashCode) +
(filesize.hashCode);
(filesize.hashCode) +
(timezone.hashCode);
@override
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize]';
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize, timezone=$timezone]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'filename'] = this.filename;
json[r'filesize'] = this.filesize;
json[r'timezone'] = this.timezone;
return json;
}
@@ -53,6 +59,7 @@ class DatabaseBackupDto {
return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!,
filesize: num.parse('${json[r'filesize']}'),
timezone: mapValueOfType<String>(json, r'timezone')!,
);
}
return null;
@@ -102,6 +109,7 @@ class DatabaseBackupDto {
static const requiredKeys = <String>{
'filename',
'filesize',
'timezone',
};
}

View File

@@ -17721,11 +17721,15 @@
},
"filesize": {
"type": "number"
},
"timezone": {
"type": "string"
}
},
"required": [
"filename",
"filesize"
"filesize",
"timezone"
],
"type": "object"
},

View File

@@ -63,6 +63,7 @@ export type DatabaseBackupDeleteDto = {
export type DatabaseBackupDto = {
filename: string;
filesize: number;
timezone: string;
};
export type DatabaseBackupListResponseDto = {
backups: DatabaseBackupDto[];

View File

@@ -4,6 +4,7 @@ import { IsString } from 'class-validator';
export class DatabaseBackupDto {
filename!: string;
filesize!: number;
timezone!: string;
}
export class DatabaseBackupListResponseDto {

View File

@@ -283,6 +283,7 @@ export class DatabaseBackupService {
async listBackups(): Promise<DatabaseBackupListResponseDto> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
const files = await this.storageRepository.readdir(backupsFolder);
const timezone = DateTime.local().zoneName;
const validFiles = files
.filter((fn) => isValidDatabaseBackupName(fn))
@@ -292,7 +293,7 @@ export class DatabaseBackupService {
const backups = await Promise.all(
validFiles.map(async (filename) => {
const stats = await this.storageRepository.stat(path.join(backupsFolder, filename));
return { filename, filesize: stats.size };
return { filename, filesize: stats.size, timezone };
}),
);

View File

@@ -0,0 +1,54 @@
import { locale } from '$lib/stores/preferences.store';
import { renderWithTooltips } from '$tests/helpers';
import { screen } from '@testing-library/svelte';
import { DateTime } from 'luxon';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MaintenanceBackupEntry from './MaintenanceBackupEntry.svelte';
vi.mock('$lib/services/database-backups.service', () => ({
getDatabaseBackupActions: () => ({
Download: { type: 'command', title: 'Download', onAction: vi.fn() },
Delete: { type: 'command', title: 'Delete', onAction: vi.fn() },
}),
handleRestoreDatabaseBackup: vi.fn(),
}));
describe('MaintenanceBackupEntry', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-24T12:00:00Z'));
locale.set('en');
});
afterEach(() => {
vi.useRealTimers();
});
it('renders relative backup time using the user timezone instead of UTC', () => {
const backupTimestamp = '20260324T110000';
const expectedRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
zone: 'Asia/Tokyo',
})
.toLocal()
.toRelative({ locale: 'en' });
const utcRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
zone: 'UTC',
})
.toLocal()
.toRelative({ locale: 'en' });
expect(expectedRelativeTime).toBeTruthy();
expect(expectedRelativeTime).not.toEqual(utcRelativeTime);
renderWithTooltips(MaintenanceBackupEntry, {
expectedVersion: '1.2.3',
filename: 'immich-db-backup-20260324T110000-v1.2.3-snapshot.sql.gz',
filesize: 1024,
timezone: 'Asia/Tokyo',
});
expect(screen.getByText(expectedRelativeTime!)).toBeInTheDocument();
});
});

View File

@@ -13,16 +13,17 @@
filename: string;
filesize: number;
expectedVersion: string;
timezone?: string;
};
const { filename, filesize, expectedVersion }: Props = $props();
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: 'utc' }).toLocal();
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }).toLocal();
}
return null;
});

View File

@@ -51,12 +51,13 @@
const unknownDateKey = $t('unknown_date');
for (const backup of backups) {
const timezone = backup.timezone;
const dateMatch = backup.filename.match(/\d+T\d+/);
let dateKey: string;
let dt: DateTime;
if (dateMatch) {
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' });
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone });
dateKey = dt.toFormat('LLLL d, yyyy');
} else {
dt = DateTime.fromMillis(0);
@@ -128,6 +129,7 @@
filename={backup.filename}
filesize={backup.filesize}
expectedVersion={props.expectedVersion}
timezone={backup.timezone}
/>
{/each}
</Stack>