mirror of
https://github.com/immich-app/immich.git
synced 2026-05-11 19:01:52 +03:00
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:
committed by
GitHub
parent
fbe631fe91
commit
95c1f0efeb
14
mobile/openapi/lib/model/database_backup_dto.dart
generated
14
mobile/openapi/lib/model/database_backup_dto.dart
generated
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17721,11 +17721,15 @@
|
||||
},
|
||||
"filesize": {
|
||||
"type": "number"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"filename",
|
||||
"filesize"
|
||||
"filesize",
|
||||
"timezone"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -63,6 +63,7 @@ export type DatabaseBackupDeleteDto = {
|
||||
export type DatabaseBackupDto = {
|
||||
filename: string;
|
||||
filesize: number;
|
||||
timezone: string;
|
||||
};
|
||||
export type DatabaseBackupListResponseDto = {
|
||||
backups: DatabaseBackupDto[];
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IsString } from 'class-validator';
|
||||
export class DatabaseBackupDto {
|
||||
filename!: string;
|
||||
filesize!: number;
|
||||
timezone!: string;
|
||||
}
|
||||
|
||||
export class DatabaseBackupListResponseDto {
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user