mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 07:47:41 +09:00 
			
		
		
		
	feat: track upgrade history (#13097)
This commit is contained in:
		
							
								
								
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -183,6 +183,7 @@ Class | Method | HTTP request | Description | ||||
| *ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage |  | ||||
| *ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types |  | ||||
| *ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme |  | ||||
| *ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history |  | ||||
| *ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping |  | ||||
| *ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license |  | ||||
| *SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions |  | ||||
| @@ -400,6 +401,7 @@ Class | Method | HTTP request | Description | ||||
|  - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md) | ||||
|  - [ServerStorageResponseDto](doc//ServerStorageResponseDto.md) | ||||
|  - [ServerThemeDto](doc//ServerThemeDto.md) | ||||
|  - [ServerVersionHistoryResponseDto](doc//ServerVersionHistoryResponseDto.md) | ||||
|  - [ServerVersionResponseDto](doc//ServerVersionResponseDto.md) | ||||
|  - [SessionResponseDto](doc//SessionResponseDto.md) | ||||
|  - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							| @@ -213,6 +213,7 @@ part 'model/server_ping_response.dart'; | ||||
| part 'model/server_stats_response_dto.dart'; | ||||
| part 'model/server_storage_response_dto.dart'; | ||||
| part 'model/server_theme_dto.dart'; | ||||
| part 'model/server_version_history_response_dto.dart'; | ||||
| part 'model/server_version_response_dto.dart'; | ||||
| part 'model/session_response_dto.dart'; | ||||
| part 'model/shared_link_create_dto.dart'; | ||||
|   | ||||
							
								
								
									
										44
									
								
								mobile/openapi/lib/api/server_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										44
									
								
								mobile/openapi/lib/api/server_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -418,6 +418,50 @@ class ServerApi { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   /// Performs an HTTP 'GET /server/version-history' operation and returns the [Response]. | ||||
|   Future<Response> getVersionHistoryWithHttpInfo() async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/server/version-history'; | ||||
| 
 | ||||
|     // ignore: prefer_final_locals | ||||
|     Object? postBody; | ||||
| 
 | ||||
|     final queryParams = <QueryParam>[]; | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|     const contentTypes = <String>[]; | ||||
| 
 | ||||
| 
 | ||||
|     return apiClient.invokeAPI( | ||||
|       path, | ||||
|       'GET', | ||||
|       queryParams, | ||||
|       postBody, | ||||
|       headerParams, | ||||
|       formParams, | ||||
|       contentTypes.isEmpty ? null : contentTypes.first, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   Future<List<ServerVersionHistoryResponseDto>?> getVersionHistory() async { | ||||
|     final response = await getVersionHistoryWithHttpInfo(); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|     // When a remote server returns no body with a status of 204, we shall not decode it. | ||||
|     // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" | ||||
|     // FormatException when trying to decode an empty string. | ||||
|     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { | ||||
|       final responseBody = await _decodeBodyBytes(response); | ||||
|       return (await apiClient.deserializeAsync(responseBody, 'List<ServerVersionHistoryResponseDto>') as List) | ||||
|         .cast<ServerVersionHistoryResponseDto>() | ||||
|         .toList(growable: false); | ||||
| 
 | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   /// Performs an HTTP 'GET /server/ping' operation and returns the [Response]. | ||||
|   Future<Response> pingServerWithHttpInfo() async { | ||||
|     // ignore: prefer_const_declarations | ||||
|   | ||||
							
								
								
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							| @@ -480,6 +480,8 @@ class ApiClient { | ||||
|           return ServerStorageResponseDto.fromJson(value); | ||||
|         case 'ServerThemeDto': | ||||
|           return ServerThemeDto.fromJson(value); | ||||
|         case 'ServerVersionHistoryResponseDto': | ||||
|           return ServerVersionHistoryResponseDto.fromJson(value); | ||||
|         case 'ServerVersionResponseDto': | ||||
|           return ServerVersionResponseDto.fromJson(value); | ||||
|         case 'SessionResponseDto': | ||||
|   | ||||
							
								
								
									
										115
									
								
								mobile/openapi/lib/model/server_version_history_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								mobile/openapi/lib/model/server_version_history_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,115 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.18 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| part of openapi.api; | ||||
| 
 | ||||
| class ServerVersionHistoryResponseDto { | ||||
|   /// Returns a new [ServerVersionHistoryResponseDto] instance. | ||||
|   ServerVersionHistoryResponseDto({ | ||||
|     required this.createdAt, | ||||
|     required this.id, | ||||
|     required this.version, | ||||
|   }); | ||||
| 
 | ||||
|   DateTime createdAt; | ||||
| 
 | ||||
|   String id; | ||||
| 
 | ||||
|   String version; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is ServerVersionHistoryResponseDto && | ||||
|     other.createdAt == createdAt && | ||||
|     other.id == id && | ||||
|     other.version == version; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (createdAt.hashCode) + | ||||
|     (id.hashCode) + | ||||
|     (version.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'ServerVersionHistoryResponseDto[createdAt=$createdAt, id=$id, version=$version]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); | ||||
|       json[r'id'] = this.id; | ||||
|       json[r'version'] = this.version; | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
|   /// Returns a new [ServerVersionHistoryResponseDto] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static ServerVersionHistoryResponseDto? fromJson(dynamic value) { | ||||
|     upgradeDto(value, "ServerVersionHistoryResponseDto"); | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
| 
 | ||||
|       return ServerVersionHistoryResponseDto( | ||||
|         createdAt: mapDateTime(json, r'createdAt', r'')!, | ||||
|         id: mapValueOfType<String>(json, r'id')!, | ||||
|         version: mapValueOfType<String>(json, r'version')!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   static List<ServerVersionHistoryResponseDto> listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <ServerVersionHistoryResponseDto>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = ServerVersionHistoryResponseDto.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, ServerVersionHistoryResponseDto> mapFromJson(dynamic json) { | ||||
|     final map = <String, ServerVersionHistoryResponseDto>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = ServerVersionHistoryResponseDto.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   // maps a json object with a list of ServerVersionHistoryResponseDto-objects as value to a dart map | ||||
|   static Map<String, List<ServerVersionHistoryResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<ServerVersionHistoryResponseDto>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       // ignore: parameter_assignments | ||||
|       json = json.cast<String, dynamic>(); | ||||
|       for (final entry in json.entries) { | ||||
|         map[entry.key] = ServerVersionHistoryResponseDto.listFromJson(entry.value, growable: growable,); | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'createdAt', | ||||
|     'id', | ||||
|     'version', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| @@ -5088,6 +5088,30 @@ | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/server/version-history": { | ||||
|       "get": { | ||||
|         "operationId": "getVersionHistory", | ||||
|         "parameters": [], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "items": { | ||||
|                     "$ref": "#/components/schemas/ServerVersionHistoryResponseDto" | ||||
|                   }, | ||||
|                   "type": "array" | ||||
|                 } | ||||
|               } | ||||
|             }, | ||||
|             "description": "" | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "Server" | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/sessions": { | ||||
|       "delete": { | ||||
|         "operationId": "deleteAllSessions", | ||||
| @@ -11042,6 +11066,26 @@ | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "ServerVersionHistoryResponseDto": { | ||||
|         "properties": { | ||||
|           "createdAt": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "id": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "version": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "createdAt", | ||||
|           "id", | ||||
|           "version" | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "ServerVersionResponseDto": { | ||||
|         "properties": { | ||||
|           "major": { | ||||
|   | ||||
| @@ -1000,6 +1000,11 @@ export type ServerVersionResponseDto = { | ||||
|     minor: number; | ||||
|     patch: number; | ||||
| }; | ||||
| export type ServerVersionHistoryResponseDto = { | ||||
|     createdAt: string; | ||||
|     id: string; | ||||
|     version: string; | ||||
| }; | ||||
| export type SessionResponseDto = { | ||||
|     createdAt: string; | ||||
|     current: boolean; | ||||
| @@ -2667,6 +2672,14 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) { | ||||
|         ...opts | ||||
|     })); | ||||
| } | ||||
| export function getVersionHistory(opts?: Oazapfts.RequestOpts) { | ||||
|     return oazapfts.ok(oazapfts.fetchJson<{ | ||||
|         status: 200; | ||||
|         data: ServerVersionHistoryResponseDto[]; | ||||
|     }>("/server/version-history", { | ||||
|         ...opts | ||||
|     })); | ||||
| } | ||||
| export function deleteAllSessions(opts?: Oazapfts.RequestOpts) { | ||||
|     return oazapfts.ok(oazapfts.fetchText("/sessions", { | ||||
|         ...opts, | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import { | ||||
|   ServerStatsResponseDto, | ||||
|   ServerStorageResponseDto, | ||||
|   ServerThemeDto, | ||||
|   ServerVersionHistoryResponseDto, | ||||
|   ServerVersionResponseDto, | ||||
| } from 'src/dtos/server.dto'; | ||||
| import { Authenticated } from 'src/middleware/auth.guard'; | ||||
| @@ -46,6 +47,11 @@ export class ServerController { | ||||
|     return this.versionService.getVersion(); | ||||
|   } | ||||
|  | ||||
|   @Get('version-history') | ||||
|   getVersionHistory(): Promise<ServerVersionHistoryResponseDto[]> { | ||||
|     return this.versionService.getVersionHistory(); | ||||
|   } | ||||
|  | ||||
|   @Get('features') | ||||
|   getServerFeatures(): Promise<ServerFeaturesDto> { | ||||
|     return this.service.getFeatures(); | ||||
|   | ||||
| @@ -68,6 +68,12 @@ export class ServerVersionResponseDto { | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class ServerVersionHistoryResponseDto { | ||||
|   id!: string; | ||||
|   createdAt!: Date; | ||||
|   version!: string; | ||||
| } | ||||
|  | ||||
| export class UsageByUserDto { | ||||
|   @ApiProperty({ type: 'string' }) | ||||
|   userId!: string; | ||||
|   | ||||
| @@ -25,6 +25,7 @@ import { SystemMetadataEntity } from 'src/entities/system-metadata.entity'; | ||||
| import { TagEntity } from 'src/entities/tag.entity'; | ||||
| import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; | ||||
| import { UserEntity } from 'src/entities/user.entity'; | ||||
| import { VersionHistoryEntity } from 'src/entities/version-history.entity'; | ||||
|  | ||||
| export const entities = [ | ||||
|   ActivityEntity, | ||||
| @@ -54,4 +55,5 @@ export const entities = [ | ||||
|   UserMetadataEntity, | ||||
|   SessionEntity, | ||||
|   LibraryEntity, | ||||
|   VersionHistoryEntity, | ||||
| ]; | ||||
|   | ||||
							
								
								
									
										13
									
								
								server/src/entities/version-history.entity.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								server/src/entities/version-history.entity.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; | ||||
|  | ||||
| @Entity('version_history') | ||||
| export class VersionHistoryEntity { | ||||
|   @PrimaryGeneratedColumn('uuid') | ||||
|   id!: string; | ||||
|  | ||||
|   @CreateDateColumn({ type: 'timestamptz' }) | ||||
|   createdAt!: Date; | ||||
|  | ||||
|   @Column() | ||||
|   version!: string; | ||||
| } | ||||
| @@ -17,6 +17,7 @@ export enum DatabaseLock { | ||||
|   Migrations = 200, | ||||
|   SystemFileMounts = 300, | ||||
|   StorageTemplateMigration = 420, | ||||
|   VersionHistory = 500, | ||||
|   CLIPDimSize = 512, | ||||
|   LibraryWatch = 1337, | ||||
|   GetSystemConfig = 69, | ||||
|   | ||||
							
								
								
									
										9
									
								
								server/src/interfaces/version-history.interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								server/src/interfaces/version-history.interface.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import { VersionHistoryEntity } from 'src/entities/version-history.entity'; | ||||
|  | ||||
| export const IVersionHistoryRepository = 'IVersionHistoryRepository'; | ||||
|  | ||||
| export interface IVersionHistoryRepository { | ||||
|   create(version: Omit<VersionHistoryEntity, 'id' | 'createdAt'>): Promise<VersionHistoryEntity>; | ||||
|   getAll(): Promise<VersionHistoryEntity[]>; | ||||
|   getLatest(): Promise<VersionHistoryEntity | null>; | ||||
| } | ||||
							
								
								
									
										14
									
								
								server/src/migrations/1727797340951-AddVersionHistory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								server/src/migrations/1727797340951-AddVersionHistory.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import { MigrationInterface, QueryRunner } from "typeorm"; | ||||
|  | ||||
| export class AddVersionHistory1727797340951 implements MigrationInterface { | ||||
|     name = 'AddVersionHistory1727797340951' | ||||
|  | ||||
|     public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|         await queryRunner.query(`CREATE TABLE "version_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "version" character varying NOT NULL, CONSTRAINT "PK_5db259cbb09ce82c0d13cfd1b23" PRIMARY KEY ("id"))`); | ||||
|     } | ||||
|  | ||||
|     public async down(queryRunner: QueryRunner): Promise<void> { | ||||
|         await queryRunner.query(`DROP TABLE "version_history"`); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -32,6 +32,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf | ||||
| import { ITagRepository } from 'src/interfaces/tag.interface'; | ||||
| import { ITrashRepository } from 'src/interfaces/trash.interface'; | ||||
| import { IUserRepository } from 'src/interfaces/user.interface'; | ||||
| import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; | ||||
| import { IViewRepository } from 'src/interfaces/view.interface'; | ||||
| import { AccessRepository } from 'src/repositories/access.repository'; | ||||
| import { ActivityRepository } from 'src/repositories/activity.repository'; | ||||
| @@ -67,6 +68,7 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos | ||||
| import { TagRepository } from 'src/repositories/tag.repository'; | ||||
| import { TrashRepository } from 'src/repositories/trash.repository'; | ||||
| import { UserRepository } from 'src/repositories/user.repository'; | ||||
| import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; | ||||
| import { ViewRepository } from 'src/repositories/view-repository'; | ||||
|  | ||||
| export const repositories = [ | ||||
| @@ -104,5 +106,6 @@ export const repositories = [ | ||||
|   { provide: ITagRepository, useClass: TagRepository }, | ||||
|   { provide: ITrashRepository, useClass: TrashRepository }, | ||||
|   { provide: IUserRepository, useClass: UserRepository }, | ||||
|   { provide: IVersionHistoryRepository, useClass: VersionHistoryRepository }, | ||||
|   { provide: IViewRepository, useClass: ViewRepository }, | ||||
| ]; | ||||
|   | ||||
							
								
								
									
										25
									
								
								server/src/repositories/version-history.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								server/src/repositories/version-history.repository.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { VersionHistoryEntity } from 'src/entities/version-history.entity'; | ||||
| import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; | ||||
| import { Instrumentation } from 'src/utils/instrumentation'; | ||||
| import { Repository } from 'typeorm'; | ||||
|  | ||||
| @Instrumentation() | ||||
| @Injectable() | ||||
| export class VersionHistoryRepository implements IVersionHistoryRepository { | ||||
|   constructor(@InjectRepository(VersionHistoryEntity) private repository: Repository<VersionHistoryEntity>) {} | ||||
|  | ||||
|   async getAll(): Promise<VersionHistoryEntity[]> { | ||||
|     return this.repository.find({ order: { createdAt: 'DESC' } }); | ||||
|   } | ||||
|  | ||||
|   async getLatest(): Promise<VersionHistoryEntity | null> { | ||||
|     const results = await this.repository.find({ order: { createdAt: 'DESC' }, take: 1 }); | ||||
|     return results[0] || null; | ||||
|   } | ||||
|  | ||||
|   create(version: Omit<VersionHistoryEntity, 'id' | 'createdAt'>): Promise<VersionHistoryEntity> { | ||||
|     return this.repository.save(version); | ||||
|   } | ||||
| } | ||||
| @@ -1,17 +1,21 @@ | ||||
| import { DateTime } from 'luxon'; | ||||
| import { serverVersion } from 'src/constants'; | ||||
| import { SystemMetadataKey } from 'src/enum'; | ||||
| import { IDatabaseRepository } from 'src/interfaces/database.interface'; | ||||
| import { IEventRepository } from 'src/interfaces/event.interface'; | ||||
| import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; | ||||
| import { ILoggerRepository } from 'src/interfaces/logger.interface'; | ||||
| import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; | ||||
| import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; | ||||
| import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; | ||||
| import { VersionService } from 'src/services/version.service'; | ||||
| import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; | ||||
| import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; | ||||
| import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; | ||||
| import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; | ||||
| import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; | ||||
| import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; | ||||
| import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock'; | ||||
| import { Mocked } from 'vitest'; | ||||
|  | ||||
| const mockRelease = (version: string) => ({ | ||||
| @@ -26,26 +30,47 @@ const mockRelease = (version: string) => ({ | ||||
|  | ||||
| describe(VersionService.name, () => { | ||||
|   let sut: VersionService; | ||||
|   let databaseMock: Mocked<IDatabaseRepository>; | ||||
|   let eventMock: Mocked<IEventRepository>; | ||||
|   let jobMock: Mocked<IJobRepository>; | ||||
|   let serverMock: Mocked<IServerInfoRepository>; | ||||
|   let systemMock: Mocked<ISystemMetadataRepository>; | ||||
|   let versionMock: Mocked<IVersionHistoryRepository>; | ||||
|   let loggerMock: Mocked<ILoggerRepository>; | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     databaseMock = newDatabaseRepositoryMock(); | ||||
|     eventMock = newEventRepositoryMock(); | ||||
|     jobMock = newJobRepositoryMock(); | ||||
|     serverMock = newServerInfoRepositoryMock(); | ||||
|     systemMock = newSystemMetadataRepositoryMock(); | ||||
|     versionMock = newVersionHistoryRepositoryMock(); | ||||
|     loggerMock = newLoggerRepositoryMock(); | ||||
|  | ||||
|     sut = new VersionService(eventMock, jobMock, serverMock, systemMock, loggerMock); | ||||
|     sut = new VersionService(databaseMock, eventMock, jobMock, serverMock, systemMock, versionMock, loggerMock); | ||||
|   }); | ||||
|  | ||||
|   it('should work', () => { | ||||
|     expect(sut).toBeDefined(); | ||||
|   }); | ||||
|  | ||||
|   describe('onBootstrap', () => { | ||||
|     it('should record a new version', async () => { | ||||
|       await expect(sut.onBootstrap()).resolves.toBeUndefined(); | ||||
|       expect(versionMock.create).toHaveBeenCalledWith({ version: expect.any(String) }); | ||||
|     }); | ||||
|  | ||||
|     it('should skip a duplicate version', async () => { | ||||
|       versionMock.getLatest.mockResolvedValue({ | ||||
|         id: 'version-1', | ||||
|         createdAt: new Date(), | ||||
|         version: serverVersion.toString(), | ||||
|       }); | ||||
|       await expect(sut.onBootstrap()).resolves.toBeUndefined(); | ||||
|       expect(versionMock.create).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getVersion', () => { | ||||
|     it('should respond the server version', () => { | ||||
|       expect(sut.getVersion()).toEqual({ | ||||
| @@ -56,6 +81,14 @@ describe(VersionService.name, () => { | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getVersionHistory', () => { | ||||
|     it('should respond the server version history', async () => { | ||||
|       const upgrade = { id: 'upgrade-1', createdAt: new Date(), version: '1.0.0' }; | ||||
|       versionMock.getAll.mockResolvedValue([upgrade]); | ||||
|       await expect(sut.getVersionHistory()).resolves.toEqual([upgrade]); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('handQueueVersionCheck', () => { | ||||
|     it('should queue a version check job', async () => { | ||||
|       await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined(); | ||||
|   | ||||
| @@ -6,11 +6,13 @@ import { OnEvent } from 'src/decorators'; | ||||
| import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; | ||||
| import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; | ||||
| import { SystemMetadataKey } from 'src/enum'; | ||||
| import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; | ||||
| import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; | ||||
| import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; | ||||
| import { ILoggerRepository } from 'src/interfaces/logger.interface'; | ||||
| import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; | ||||
| import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; | ||||
| import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; | ||||
| import { BaseService } from 'src/services/base.service'; | ||||
|  | ||||
| const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { | ||||
| @@ -25,10 +27,12 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re | ||||
| @Injectable() | ||||
| export class VersionService extends BaseService { | ||||
|   constructor( | ||||
|     @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, | ||||
|     @Inject(IEventRepository) private eventRepository: IEventRepository, | ||||
|     @Inject(IJobRepository) private jobRepository: IJobRepository, | ||||
|     @Inject(IServerInfoRepository) private repository: IServerInfoRepository, | ||||
|     @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, | ||||
|     @Inject(IVersionHistoryRepository) private versionRepository: IVersionHistoryRepository, | ||||
|     @Inject(ILoggerRepository) logger: ILoggerRepository, | ||||
|   ) { | ||||
|     super(systemMetadataRepository, logger); | ||||
| @@ -38,12 +42,25 @@ export class VersionService extends BaseService { | ||||
|   @OnEvent({ name: 'app.bootstrap' }) | ||||
|   async onBootstrap(): Promise<void> { | ||||
|     await this.handleVersionCheck(); | ||||
|  | ||||
|     await this.databaseRepository.withLock(DatabaseLock.VersionHistory, async () => { | ||||
|       const latest = await this.versionRepository.getLatest(); | ||||
|       const current = serverVersion.toString(); | ||||
|       if (!latest || latest.version !== current) { | ||||
|         this.logger.log(`Version has changed, adding ${current} to history`); | ||||
|         await this.versionRepository.create({ version: current }); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   getVersion() { | ||||
|     return ServerVersionResponseDto.fromSemVer(serverVersion); | ||||
|   } | ||||
|  | ||||
|   getVersionHistory() { | ||||
|     return this.versionRepository.getAll(); | ||||
|   } | ||||
|  | ||||
|   async handleQueueVersionCheck() { | ||||
|     await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} }); | ||||
|   } | ||||
|   | ||||
							
								
								
									
										10
									
								
								server/test/repositories/version-history.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								server/test/repositories/version-history.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; | ||||
| import { Mocked, vitest } from 'vitest'; | ||||
|  | ||||
| export const newVersionHistoryRepositoryMock = (): Mocked<IVersionHistoryRepository> => { | ||||
|   return { | ||||
|     getAll: vitest.fn().mockResolvedValue([]), | ||||
|     getLatest: vitest.fn(), | ||||
|     create: vitest.fn(), | ||||
|   }; | ||||
| }; | ||||
| @@ -1,19 +1,19 @@ | ||||
| <script lang="ts"> | ||||
|   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; | ||||
|   import Portal from '$lib/components/shared-components/portal/portal.svelte'; | ||||
|   import { type ServerAboutResponseDto } from '@immich/sdk'; | ||||
|   import { type ServerAboutResponseDto, type ServerVersionHistoryResponseDto } from '@immich/sdk'; | ||||
|   import { DateTime } from 'luxon'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|  | ||||
|   export let onClose: () => void; | ||||
|  | ||||
|   export let info: ServerAboutResponseDto; | ||||
|   export let versions: ServerVersionHistoryResponseDto[]; | ||||
| </script> | ||||
|  | ||||
| <Portal> | ||||
|   <FullScreenModal title={$t('about')} {onClose}> | ||||
|     <div | ||||
|       class="immich-scrollbar max-h-[500px] overflow-y-auto flex flex-col sm:grid sm:grid-cols-2 gap-1 text-immich-primary dark:text-immich-dark-primary" | ||||
|     > | ||||
|     <div class="flex flex-col sm:grid sm:grid-cols-2 gap-1 text-immich-primary dark:text-immich-dark-primary"> | ||||
|       <div> | ||||
|         <label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc" | ||||
|           >Immich</label | ||||
| @@ -151,6 +151,35 @@ | ||||
|           </div> | ||||
|         </div> | ||||
|       {/if} | ||||
|  | ||||
|       <div class="col-span-full"> | ||||
|         <label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-history" | ||||
|           >{$t('version_history')}</label | ||||
|         > | ||||
|         <ul id="version-history" class="list-none"> | ||||
|           {#each versions.slice(0, 5) as item (item.id)} | ||||
|             {@const createdAt = DateTime.fromISO(item.createdAt)} | ||||
|             <li> | ||||
|               <span | ||||
|                 class="immich-form-label pb-2 text-xs" | ||||
|                 id="version-history" | ||||
|                 title={createdAt.toLocaleString(DateTime.DATETIME_SHORT_WITH_SECONDS)} | ||||
|               > | ||||
|                 {$t('version_history_item', { | ||||
|                   values: { | ||||
|                     version: item.version, | ||||
|                     date: createdAt.toLocaleString({ | ||||
|                       month: 'short', | ||||
|                       day: 'numeric', | ||||
|                       year: 'numeric', | ||||
|                     }), | ||||
|                   }, | ||||
|                 })} | ||||
|               </span> | ||||
|             </li> | ||||
|           {/each} | ||||
|         </ul> | ||||
|       </div> | ||||
|     </div> | ||||
|   </FullScreenModal> | ||||
| </Portal> | ||||
|   | ||||
| @@ -4,7 +4,12 @@ | ||||
|   import { requestServerInfo } from '$lib/utils/auth'; | ||||
|   import { onMount } from 'svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk'; | ||||
|   import { | ||||
|     getAboutInfo, | ||||
|     getVersionHistory, | ||||
|     type ServerAboutResponseDto, | ||||
|     type ServerVersionHistoryResponseDto, | ||||
|   } from '@immich/sdk'; | ||||
|  | ||||
|   const { serverVersion, connected } = websocketStore; | ||||
|  | ||||
| @@ -12,16 +17,17 @@ | ||||
|  | ||||
|   $: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null; | ||||
|  | ||||
|   let aboutInfo: ServerAboutResponseDto; | ||||
|   let info: ServerAboutResponseDto; | ||||
|   let versions: ServerVersionHistoryResponseDto[] = []; | ||||
|  | ||||
|   onMount(async () => { | ||||
|     await requestServerInfo(); | ||||
|     aboutInfo = await getAboutInfo(); | ||||
|     [info, versions] = await Promise.all([getAboutInfo(), getVersionHistory()]); | ||||
|   }); | ||||
| </script> | ||||
|  | ||||
| {#if isOpen} | ||||
|   <ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} /> | ||||
|   <ServerAboutModal onClose={() => (isOpen = false)} {info} {versions} /> | ||||
| {/if} | ||||
|  | ||||
| <div | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| <script lang="ts"> | ||||
|   import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import { serverInfo } from '$lib/stores/server-info.store'; | ||||
|   import { user } from '$lib/stores/user.store'; | ||||
| @@ -8,18 +7,14 @@ | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { getByteUnitString } from '../../../utils/byte-units'; | ||||
|   import LoadingSpinner from '../loading-spinner.svelte'; | ||||
|   import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk'; | ||||
|  | ||||
|   let usageClasses = ''; | ||||
|   let isOpen = false; | ||||
|  | ||||
|   $: hasQuota = $user?.quotaSizeInBytes !== null; | ||||
|   $: availableBytes = (hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0; | ||||
|   $: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0; | ||||
|   $: usedPercentage = Math.min(Math.round((usedBytes / availableBytes) * 100), 100); | ||||
|  | ||||
|   let aboutInfo: ServerAboutResponseDto; | ||||
|  | ||||
|   const onUpdate = () => { | ||||
|     usageClasses = getUsageClass(); | ||||
|   }; | ||||
| @@ -42,14 +37,9 @@ | ||||
|  | ||||
|   onMount(async () => { | ||||
|     await requestServerInfo(); | ||||
|     aboutInfo = await getAboutInfo(); | ||||
|   }); | ||||
| </script> | ||||
|  | ||||
| {#if isOpen} | ||||
|   <ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} /> | ||||
| {/if} | ||||
|  | ||||
| <div | ||||
|   class="hidden md:block storage-status p-4 bg-gray-100 dark:bg-immich-dark-primary/10 ml-4 rounded-lg text-sm" | ||||
|   title={$t('storage_usage', { | ||||
|   | ||||
| @@ -1278,6 +1278,8 @@ | ||||
|   "version": "Version", | ||||
|   "version_announcement_closing": "Your friend, Alex", | ||||
|   "version_announcement_message": "Hi friend, there is a new version of the application please take your time to visit the <link>release notes</link> and ensure your <code>docker-compose.yml</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your application automatically.", | ||||
|   "version_history": "Version History", | ||||
|   "version_history_item": "Installed {version} on {date}", | ||||
|   "video": "Video", | ||||
|   "video_hover_setting": "Play video thumbnail on hover", | ||||
|   "video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user