chore: build metadata (#10612)

feat: build metadata
This commit is contained in:
Jason Rasmussen 2024-06-26 08:25:09 -04:00 committed by GitHub
parent 15c1cd6449
commit 8a445cac07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 905 additions and 18 deletions

View File

@ -124,7 +124,11 @@ jobs:
push: ${{ !github.event.pull_request.head.repo.fork }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
cache-to: ${{ steps.cache-target.outputs.cache-to }}
build-args: |
DEVICE=${{ matrix.device }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
build-args: |
DEVICE=${{ matrix.device }}
BUILD_ID=${{ github.run_id }}
BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }}
BUILD_SOURCE_REF=${{ github.ref_name }}
BUILD_SOURCE_COMMIT=${{ github.sha }}

View File

@ -26,6 +26,16 @@ services:
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
environment:
IMMICH_REPOSITORY: immich-app/immich
IMMICH_REPOSITORY_URL: https://github.com/immich-app/immich
IMMICH_SOURCE_REF: local
IMMICH_SOURCE_COMMIT: af2efbdbbddc27cd06142f22253ccbbbbeec1f55
IMMICH_SOURCE_URL: https://github.com/immich-app/immich/commit/af2efbdbbddc27cd06142f22253ccbbbbeec1f55
IMMICH_BUILD: '9654404849'
IMMICH_BUILD_URL: https://github.com/immich-app/immich/actions/runs/9654404849
IMMICH_BUILD_IMAGE: development
IMMICH_BUILD_IMAGE_URL: https://github.com/immich-app/immich/pkgs/container/immich-server
ulimits:
nofile:
soft: 1048576
@ -107,7 +117,22 @@ services:
interval: 5m
start_interval: 30s
start_period: 5m
command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"]
command:
[
'postgres',
'-c',
'shared_preload_libraries=vectors.so',
'-c',
'search_path="$$user", public, vectors',
'-c',
'logging_collector=on',
'-c',
'max_wal_size=2GB',
'-c',
'shared_buffers=512MB',
'-c',
'wal_compression=on',
]
# set IMMICH_METRICS=true in .env to enable metrics
# immich-prometheus:

View File

@ -10,6 +10,11 @@ services:
build:
context: ../
dockerfile: server/Dockerfile
args:
- BUILD_ID=1234567890
- BUILD_IMAGE=e2e
- BUILD_SOURCE_REF=e2e
- BUILD_SOURCE_COMMIT=e2eeeeeeeeeeeeeeeeee
environment:
- DB_HOSTNAME=database
- DB_USERNAME=postgres

View File

@ -15,6 +15,39 @@ describe('/server-info', () => {
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
});
describe('GET /server-info/about', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/server-info/about');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return about information', async () => {
const { status, body } = await request(app)
.get('/server-info/about')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
version: expect.any(String),
versionUrl: expect.any(String),
repository: 'immich-app/immich',
repositoryUrl: 'https://github.com/immich-app/immich',
build: '1234567890',
buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890',
buildImage: 'e2e',
buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server',
sourceRef: 'e2e',
sourceCommit: 'e2eeeeeeeeeeeeeeeeee',
sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee',
nodejs: expect.any(String),
ffmpeg: expect.any(String),
imagemagick: expect.any(String),
libvips: expect.any(String),
exiftool: expect.any(String),
});
});
});
describe('GET /server-info/storage', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/server-info/storage');

View File

@ -171,6 +171,7 @@ Class | Method | HTTP request | Description
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
*ServerInfoApi* | [**getAboutInfo**](doc//ServerInfoApi.md#getaboutinfo) | **GET** /server-info/about |
*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config |
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
*ServerInfoApi* | [**getServerStatistics**](doc//ServerInfoApi.md#getserverstatistics) | **GET** /server-info/statistics |
@ -360,6 +361,7 @@ Class | Method | HTTP request | Description
- [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
- [SearchResponseDto](doc//SearchResponseDto.md)
- [SearchSuggestionType](doc//SearchSuggestionType.md)
- [ServerAboutResponseDto](doc//ServerAboutResponseDto.md)
- [ServerConfigDto](doc//ServerConfigDto.md)
- [ServerFeaturesDto](doc//ServerFeaturesDto.md)
- [ServerMediaTypesResponseDto](doc//ServerMediaTypesResponseDto.md)

View File

@ -187,6 +187,7 @@ part 'model/search_facet_count_response_dto.dart';
part 'model/search_facet_response_dto.dart';
part 'model/search_response_dto.dart';
part 'model/search_suggestion_type.dart';
part 'model/server_about_response_dto.dart';
part 'model/server_config_dto.dart';
part 'model/server_features_dto.dart';
part 'model/server_media_types_response_dto.dart';

View File

@ -16,6 +16,47 @@ class ServerInfoApi {
final ApiClient apiClient;
/// Performs an HTTP 'GET /server-info/about' operation and returns the [Response].
Future<Response> getAboutInfoWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/server-info/about';
// 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<ServerAboutResponseDto?> getAboutInfo() async {
final response = await getAboutInfoWithHttpInfo();
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) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerAboutResponseDto',) as ServerAboutResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /server-info/config' operation and returns the [Response].
Future<Response> getServerConfigWithHttpInfo() async {
// ignore: prefer_const_declarations

View File

@ -436,6 +436,8 @@ class ApiClient {
return SearchResponseDto.fromJson(value);
case 'SearchSuggestionType':
return SearchSuggestionTypeTypeTransformer().decode(value);
case 'ServerAboutResponseDto':
return ServerAboutResponseDto.fromJson(value);
case 'ServerConfigDto':
return ServerConfigDto.fromJson(value);
case 'ServerFeaturesDto':

View File

@ -0,0 +1,344 @@
//
// 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 ServerAboutResponseDto {
/// Returns a new [ServerAboutResponseDto] instance.
ServerAboutResponseDto({
this.build,
this.buildImage,
this.buildImageUrl,
this.buildUrl,
this.exiftool,
this.ffmpeg,
this.imagemagick,
this.libvips,
this.nodejs,
this.repository,
this.repositoryUrl,
this.sourceCommit,
this.sourceRef,
this.sourceUrl,
required this.version,
required this.versionUrl,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? build;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? buildImage;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? buildImageUrl;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? buildUrl;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? exiftool;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? ffmpeg;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? imagemagick;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? libvips;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? nodejs;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? repository;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? repositoryUrl;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? sourceCommit;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? sourceRef;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? sourceUrl;
String version;
String versionUrl;
@override
bool operator ==(Object other) => identical(this, other) || other is ServerAboutResponseDto &&
other.build == build &&
other.buildImage == buildImage &&
other.buildImageUrl == buildImageUrl &&
other.buildUrl == buildUrl &&
other.exiftool == exiftool &&
other.ffmpeg == ffmpeg &&
other.imagemagick == imagemagick &&
other.libvips == libvips &&
other.nodejs == nodejs &&
other.repository == repository &&
other.repositoryUrl == repositoryUrl &&
other.sourceCommit == sourceCommit &&
other.sourceRef == sourceRef &&
other.sourceUrl == sourceUrl &&
other.version == version &&
other.versionUrl == versionUrl;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(build == null ? 0 : build!.hashCode) +
(buildImage == null ? 0 : buildImage!.hashCode) +
(buildImageUrl == null ? 0 : buildImageUrl!.hashCode) +
(buildUrl == null ? 0 : buildUrl!.hashCode) +
(exiftool == null ? 0 : exiftool!.hashCode) +
(ffmpeg == null ? 0 : ffmpeg!.hashCode) +
(imagemagick == null ? 0 : imagemagick!.hashCode) +
(libvips == null ? 0 : libvips!.hashCode) +
(nodejs == null ? 0 : nodejs!.hashCode) +
(repository == null ? 0 : repository!.hashCode) +
(repositoryUrl == null ? 0 : repositoryUrl!.hashCode) +
(sourceCommit == null ? 0 : sourceCommit!.hashCode) +
(sourceRef == null ? 0 : sourceRef!.hashCode) +
(sourceUrl == null ? 0 : sourceUrl!.hashCode) +
(version.hashCode) +
(versionUrl.hashCode);
@override
String toString() => 'ServerAboutResponseDto[build=$build, buildImage=$buildImage, buildImageUrl=$buildImageUrl, buildUrl=$buildUrl, exiftool=$exiftool, ffmpeg=$ffmpeg, imagemagick=$imagemagick, libvips=$libvips, nodejs=$nodejs, repository=$repository, repositoryUrl=$repositoryUrl, sourceCommit=$sourceCommit, sourceRef=$sourceRef, sourceUrl=$sourceUrl, version=$version, versionUrl=$versionUrl]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.build != null) {
json[r'build'] = this.build;
} else {
// json[r'build'] = null;
}
if (this.buildImage != null) {
json[r'buildImage'] = this.buildImage;
} else {
// json[r'buildImage'] = null;
}
if (this.buildImageUrl != null) {
json[r'buildImageUrl'] = this.buildImageUrl;
} else {
// json[r'buildImageUrl'] = null;
}
if (this.buildUrl != null) {
json[r'buildUrl'] = this.buildUrl;
} else {
// json[r'buildUrl'] = null;
}
if (this.exiftool != null) {
json[r'exiftool'] = this.exiftool;
} else {
// json[r'exiftool'] = null;
}
if (this.ffmpeg != null) {
json[r'ffmpeg'] = this.ffmpeg;
} else {
// json[r'ffmpeg'] = null;
}
if (this.imagemagick != null) {
json[r'imagemagick'] = this.imagemagick;
} else {
// json[r'imagemagick'] = null;
}
if (this.libvips != null) {
json[r'libvips'] = this.libvips;
} else {
// json[r'libvips'] = null;
}
if (this.nodejs != null) {
json[r'nodejs'] = this.nodejs;
} else {
// json[r'nodejs'] = null;
}
if (this.repository != null) {
json[r'repository'] = this.repository;
} else {
// json[r'repository'] = null;
}
if (this.repositoryUrl != null) {
json[r'repositoryUrl'] = this.repositoryUrl;
} else {
// json[r'repositoryUrl'] = null;
}
if (this.sourceCommit != null) {
json[r'sourceCommit'] = this.sourceCommit;
} else {
// json[r'sourceCommit'] = null;
}
if (this.sourceRef != null) {
json[r'sourceRef'] = this.sourceRef;
} else {
// json[r'sourceRef'] = null;
}
if (this.sourceUrl != null) {
json[r'sourceUrl'] = this.sourceUrl;
} else {
// json[r'sourceUrl'] = null;
}
json[r'version'] = this.version;
json[r'versionUrl'] = this.versionUrl;
return json;
}
/// Returns a new [ServerAboutResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ServerAboutResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return ServerAboutResponseDto(
build: mapValueOfType<String>(json, r'build'),
buildImage: mapValueOfType<String>(json, r'buildImage'),
buildImageUrl: mapValueOfType<String>(json, r'buildImageUrl'),
buildUrl: mapValueOfType<String>(json, r'buildUrl'),
exiftool: mapValueOfType<String>(json, r'exiftool'),
ffmpeg: mapValueOfType<String>(json, r'ffmpeg'),
imagemagick: mapValueOfType<String>(json, r'imagemagick'),
libvips: mapValueOfType<String>(json, r'libvips'),
nodejs: mapValueOfType<String>(json, r'nodejs'),
repository: mapValueOfType<String>(json, r'repository'),
repositoryUrl: mapValueOfType<String>(json, r'repositoryUrl'),
sourceCommit: mapValueOfType<String>(json, r'sourceCommit'),
sourceRef: mapValueOfType<String>(json, r'sourceRef'),
sourceUrl: mapValueOfType<String>(json, r'sourceUrl'),
version: mapValueOfType<String>(json, r'version')!,
versionUrl: mapValueOfType<String>(json, r'versionUrl')!,
);
}
return null;
}
static List<ServerAboutResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ServerAboutResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ServerAboutResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ServerAboutResponseDto> mapFromJson(dynamic json) {
final map = <String, ServerAboutResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ServerAboutResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ServerAboutResponseDto-objects as value to a dart map
static Map<String, List<ServerAboutResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ServerAboutResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ServerAboutResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'version',
'versionUrl',
};
}

View File

@ -4718,6 +4718,38 @@
]
}
},
"/server-info/about": {
"get": {
"operationId": "getAboutInfo",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerAboutResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Server Info"
]
}
},
"/server-info/config": {
"get": {
"operationId": "getServerConfig",
@ -9630,6 +9662,63 @@
],
"type": "string"
},
"ServerAboutResponseDto": {
"properties": {
"build": {
"type": "string"
},
"buildImage": {
"type": "string"
},
"buildImageUrl": {
"type": "string"
},
"buildUrl": {
"type": "string"
},
"exiftool": {
"type": "string"
},
"ffmpeg": {
"type": "string"
},
"imagemagick": {
"type": "string"
},
"libvips": {
"type": "string"
},
"nodejs": {
"type": "string"
},
"repository": {
"type": "string"
},
"repositoryUrl": {
"type": "string"
},
"sourceCommit": {
"type": "string"
},
"sourceRef": {
"type": "string"
},
"sourceUrl": {
"type": "string"
},
"version": {
"type": "string"
},
"versionUrl": {
"type": "string"
}
},
"required": [
"version",
"versionUrl"
],
"type": "object"
},
"ServerConfigDto": {
"properties": {
"externalDomain": {

View File

@ -787,6 +787,24 @@ export type SmartSearchDto = {
withDeleted?: boolean;
withExif?: boolean;
};
export type ServerAboutResponseDto = {
build?: string;
buildImage?: string;
buildImageUrl?: string;
buildUrl?: string;
exiftool?: string;
ffmpeg?: string;
imagemagick?: string;
libvips?: string;
nodejs?: string;
repository?: string;
repositoryUrl?: string;
sourceCommit?: string;
sourceRef?: string;
sourceUrl?: string;
version: string;
versionUrl: string;
};
export type ServerConfigDto = {
externalDomain: string;
isInitialized: boolean;
@ -2363,6 +2381,14 @@ export function getSearchSuggestions({ country, make, model, state, $type }: {
...opts
}));
}
export function getAboutInfo(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ServerAboutResponseDto;
}>("/server-info/about", {
...opts
}));
}
export function getServerConfig(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;

View File

@ -59,6 +59,22 @@ RUN npm link && npm install -g @immich/cli && npm cache clean --force
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
ENV PATH="${PATH}:/usr/src/app/bin"
ARG BUILD_ID
ARG BUILD_IMAGE
ARG BUILD_SOURCE_REF
ARG BUILD_SOURCE_COMMIT
ENV IMMICH_BUILD=${BUILD_ID}
ENV IMMICH_BUILD_URL=https://github.com/immich-app/immich/actions/runs/${BUILD_ID}
ENV IMMICH_BUILD_IMAGE=${BUILD_IMAGE}
ENV IMMICH_BUILD_IMAGE_URL=https://github.com/immich-app/immich/pkgs/container/immich-server
ENV IMMICH_REPOSITORY=immich-app/immich
ENV IMMICH_REPOSITORY_URL=https://github.com/immich-app/immich
ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF}
ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT}
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT}
VOLUME /usr/src/app/upload
EXPOSE 3001
ENTRYPOINT ["tini", "--", "/bin/bash"]

View File

@ -429,3 +429,15 @@ export const clsConfig: ClsModuleOptions = {
},
},
};
export const getBuildMetadata = () => ({
build: process.env.IMMICH_BUILD,
buildUrl: process.env.IMMICH_BUILD_URL,
buildImage: process.env.IMMICH_BUILD_IMAGE,
buildImageUrl: process.env.IMMICH_BUILD_IMAGE_URL,
repository: process.env.IMMICH_REPOSITORY,
repositoryUrl: process.env.IMMICH_REPOSITORY_URL,
sourceRef: process.env.IMMICH_SOURCE_REF,
sourceCommit: process.env.IMMICH_SOURCE_COMMIT,
sourceUrl: process.env.IMMICH_SOURCE_URL,
});

View File

@ -1,6 +1,7 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
ServerAboutResponseDto,
ServerConfigDto,
ServerFeaturesDto,
ServerMediaTypesResponseDto,
@ -22,6 +23,12 @@ export class ServerInfoController {
private versionService: VersionService,
) {}
@Get('about')
@Authenticated()
getAboutInfo(): Promise<ServerAboutResponseDto> {
return this.service.getAboutInfo();
}
@Get('storage')
@Authenticated()
getStorage(): Promise<ServerStorageResponseDto> {

View File

@ -7,6 +7,29 @@ export class ServerPingResponse {
res!: string;
}
export class ServerAboutResponseDto {
version!: string;
versionUrl!: string;
repository?: string;
repositoryUrl?: string;
sourceRef?: string;
sourceCommit?: string;
sourceUrl?: string;
build?: string;
buildUrl?: string;
buildImage?: string;
buildImageUrl?: string;
nodejs?: string;
ffmpeg?: string;
imagemagick?: string;
libvips?: string;
exiftool?: string;
}
export class ServerStorageResponseDto {
diskSize!: string;
diskUse!: string;

View File

@ -8,8 +8,17 @@ export interface GitHubRelease {
body: string;
}
export interface ServerBuildVersions {
nodejs: string;
ffmpeg: string;
libvips: string;
exiftool: string;
imagemagick: string;
}
export const IServerInfoRepository = 'IServerInfoRepository';
export interface IServerInfoRepository {
getGitHubRelease(): Promise<GitHubRelease>;
getBuildVersions(): Promise<ServerBuildVersions>;
}

View File

@ -1,10 +1,45 @@
import { Injectable } from '@nestjs/common';
import { GitHubRelease, IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { Inject, Injectable } from '@nestjs/common';
import { exiftool } from 'exiftool-vendored';
import { exec as execCallback } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface';
import { Instrumentation } from 'src/utils/instrumentation';
const exec = promisify(execCallback);
const maybeFirstLine = async (command: string): Promise<string> => {
try {
const { stdout } = await exec(command);
return stdout.trim().split('\n')[0] || '';
} catch {
return '';
}
};
type BuildLockfile = {
sources: Array<{ name: string; version: string }>;
packages: Array<{ name: string; version: string }>;
};
const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => {
if (!lockfile) {
return;
}
const items = [...(lockfile.sources || []), ...(lockfile?.packages || [])];
const item = items.find((item) => item.name === name);
return item?.version;
};
@Instrumentation()
@Injectable()
export class ServerInfoRepository implements IServerInfoRepository {
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
this.logger.setContext(ServerInfoRepository.name);
}
async getGitHubRelease(): Promise<GitHubRelease> {
try {
const response = await fetch('https://api.github.com/repos/immich-app/immich/releases/latest');
@ -18,4 +53,25 @@ export class ServerInfoRepository implements IServerInfoRepository {
throw new Error(`Failed to fetch GitHub release: ${error}`);
}
}
async getBuildVersions(): Promise<ServerBuildVersions> {
const [nodejsOutput, ffmpegOutput, magickOutput] = await Promise.all([
maybeFirstLine('node --version'),
maybeFirstLine('ffmpeg -version'),
maybeFirstLine('convert --version'),
]);
const lockfile = await readFile('build-lock.json')
.then((buffer) => JSON.parse(buffer.toString()))
.catch(() => this.logger.warn('Failed to read build-lock.json'));
return {
nodejs: nodejsOutput || process.env.NODE_VERSION || '',
exiftool: await exiftool.version(),
ffmpeg: getLockfileVersion('ffmpeg', lockfile) || ffmpegOutput.replaceAll('ffmpeg version', '') || '',
libvips: getLockfileVersion('libvips', lockfile) || sharp.versions.vips,
imagemagick:
getLockfileVersion('imagemagick', lockfile) || magickOutput.replaceAll('Version: ImageMagick ', '') || '',
};
}
}

View File

@ -1,9 +1,11 @@
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { ServerInfoService } from 'src/services/server-info.service';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
@ -13,16 +15,18 @@ describe(ServerInfoService.name, () => {
let sut: ServerInfoService;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>;
let serverInfoMock: Mocked<IServerInfoRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
serverInfoMock = newServerInfoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new ServerInfoService(userMock, storageMock, systemMock, loggerMock);
sut = new ServerInfoService(userMock, storageMock, systemMock, serverInfoMock, loggerMock);
});
it('should work', () => {

View File

@ -1,7 +1,10 @@
import { Inject, Injectable } from '@nestjs/common';
import { getBuildMetadata } from 'src/config';
import { serverVersion } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import {
ServerAboutResponseDto,
ServerConfigDto,
ServerFeaturesDto,
ServerMediaTypesResponseDto,
@ -12,6 +15,7 @@ import {
} from 'src/dtos/server-info.dto';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface';
@ -27,6 +31,7 @@ export class ServerInfoService {
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
@Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(ServerInfoService.name);
@ -42,6 +47,19 @@ export class ServerInfoService {
}
}
async getAboutInfo(): Promise<ServerAboutResponseDto> {
const version = serverVersion.toString();
const buildMetadata = getBuildMetadata();
const buildVersions = await this.serverInfoRepository.getBuildVersions();
return {
version,
versionUrl: `https://github.com/immich-app/immich/releases/tag/${version}`,
...buildMetadata,
...buildVersions,
};
}
async getStorage(): Promise<ServerStorageResponseDto> {
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase);

View File

@ -10,7 +10,7 @@ import { VersionService } from 'src/services/version.service';
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/system-info.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest';

View File

@ -4,5 +4,6 @@ import { Mocked, vitest } from 'vitest';
export const newServerInfoRepositoryMock = (): Mocked<IServerInfoRepository> => {
return {
getGitHubRelease: vitest.fn(),
getBuildVersions: vitest.fn(),
};
};

View File

@ -0,0 +1,156 @@
<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 { t } from 'svelte-i18n';
export let onClose: () => void;
export let info: ServerAboutResponseDto;
</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>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc"
>Immich</label
>
<div>
<a
href={info.versionUrl}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="version-desc"
>
{info.version}
</a>
</div>
</div>
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="ffmpeg-desc"
>ExifTool</label
>
<p class="immich-form-label pb-2 text-sm" id="ffmpeg-desc">
{info.exiftool}
</p>
</div>
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="nodejs-desc"
>Node.js</label
>
<p class="immich-form-label pb-2 text-sm" id="nodejs-desc">
{info.nodejs}
</p>
</div>
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="vips-desc"
>Libvips</label
>
<p class="immich-form-label pb-2 text-sm" id="vips-desc">
{info.libvips}
</p>
</div>
<div class={(info.imagemagick?.length || 0) > 10 ? 'col-span-2' : ''}>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="imagemagick-desc"
>ImageMagick</label
>
<p class="immich-form-label pb-2 text-sm" id="imagemagick-desc">
{info.imagemagick}
</p>
</div>
<div class={(info.ffmpeg?.length || 0) > 10 ? 'col-span-2' : ''}>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="ffmpeg-desc"
>FFmpeg</label
>
<p class="immich-form-label pb-2 text-sm" id="ffmpeg-desc">
{info.ffmpeg}
</p>
</div>
{#if info.repository && info.repositoryUrl}
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc"
>{$t('repository')}</label
>
<div>
<a
href={info.repositoryUrl}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="version-desc"
>
{info.repository}
</a>
</div>
</div>
{/if}
{#if info.sourceRef && info.sourceCommit && info.sourceUrl}
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="git-desc"
>{$t('source')}</label
>
<div>
<a
href={info.sourceUrl}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="git-desc"
>
{info.sourceRef}@{info.sourceCommit.slice(0, 9)}
</a>
</div>
</div>
{/if}
{#if info.build && info.buildUrl}
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="build-desc"
>{$t('build')}</label
>
<div>
<a
href={info.buildUrl}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="build-desc"
>
{info.build}
</a>
</div>
</div>
{/if}
{#if info.buildImage && info.buildImage}
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="build-image-desc"
>{$t('build_image')}</label
>
<div>
<a
href={info.buildImageUrl}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="build-image-desc"
>
{info.buildImage}
</a>
</div>
</div>
{/if}
</div>
</FullScreenModal>
</Portal>

View File

@ -1,19 +1,22 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte';
import { locale } from '$lib/stores/preferences.store';
import { websocketStore } from '$lib/stores/websocket';
import { onMount } from 'svelte';
import { getByteUnitString } from '../../utils/byte-units';
import LoadingSpinner from './loading-spinner.svelte';
import { mdiChartPie, mdiDns } from '@mdi/js';
import { serverInfo } from '$lib/stores/server-info.store';
import { user } from '$lib/stores/user.store';
import { websocketStore } from '$lib/stores/websocket';
import { requestServerInfo } from '$lib/utils/auth';
import { mdiChartPie, mdiDns } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { getByteUnitString } from '../../utils/byte-units';
import LoadingSpinner from './loading-spinner.svelte';
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
const { serverVersion, connected } = websocketStore;
let usageClasses = '';
let isOpen = false;
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
$: hasQuota = $user?.quotaSizeInBytes !== null;
@ -21,6 +24,8 @@
$: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0;
$: usedPercentage = Math.round((usedBytes / availableBytes) * 100);
let aboutInfo: ServerAboutResponseDto;
const onUpdate = () => {
usageClasses = getUsageClass();
};
@ -41,9 +46,14 @@
onMount(async () => {
await requestServerInfo();
aboutInfo = await getAboutInfo();
});
</script>
{#if isOpen}
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} />
{/if}
<div class="dark:text-immich-dark-fg">
<div
class="storage-status grid grid-cols-[64px_auto]"
@ -96,13 +106,11 @@
<div class="mt-2 flex justify-between justify-items-center">
<p>{$t('version')}</p>
{#if $connected && version}
<a
href="https://github.com/immich-app/immich/releases"
class="font-medium text-immich-primary dark:text-immich-dark-primary"
target="_blank"
<button
type="button"
on:click={() => (isOpen = true)}
class="font-medium text-immich-primary dark:text-immich-dark-primary">{version}</button
>
{version}
</a>
{:else}
<p class="font-medium text-red-500">{$t('unknown')}</p>
{/if}

View File

@ -1,4 +1,5 @@
{
"about": "About",
"account": "Account",
"account_settings": "Account Settings",
"acknowledge": "Acknowledge",
@ -380,6 +381,8 @@
"birthdate_saved": "Date of birth saved successfully",
"birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.",
"blurred_background": "Blurred background",
"build": "Build",
"build_image": "Build Image",
"bulk_delete_duplicates_confirmation": "Are you sure you want to bulk delete {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and permanently delete all other duplicates. You cannot undo this action!",
"bulk_keep_duplicates_confirmation": "Are you sure you want to keep {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will resolve all duplicate groups without deleting anything.",
"bulk_trash_duplicates_confirmation": "Are you sure you want to bulk trash {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and trash all other duplicates.",
@ -904,6 +907,7 @@
"repair": "Repair",
"repair_no_results_message": "Untracked and missing files will show up here",
"replace_with_upload": "Replace with upload",
"repository": "Repository",
"require_password": "Require password",
"require_user_to_change_password_on_first_login": "Require user to change password on first login",
"reset": "Reset",
@ -1016,6 +1020,7 @@
"sort_oldest": "Oldest photo",
"sort_recent": "Most recent photo",
"sort_title": "Title",
"source": "Source",
"stack": "Stack",
"stack_selected_photos": "Stack selected photos",
"stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",