mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 06:31:58 +00:00
parent
15c1cd6449
commit
8a445cac07
24 changed files with 515 additions and 18 deletions
8
.github/workflows/docker.yml
vendored
8
.github/workflows/docker.yml
vendored
|
@ -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 }}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
|
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/server_info_api.dart
generated
BIN
mobile/openapi/lib/api/server_info_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/server_about_response_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/server_about_response_dto.dart
generated
Normal file
Binary file not shown.
|
@ -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": {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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 ', '') || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -4,5 +4,6 @@ import { Mocked, vitest } from 'vitest';
|
|||
export const newServerInfoRepositoryMock = (): Mocked<IServerInfoRepository> => {
|
||||
return {
|
||||
getGitHubRelease: vitest.fn(),
|
||||
getBuildVersions: vitest.fn(),
|
||||
};
|
||||
};
|
|
@ -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>
|
|
@ -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}
|
||||
|
|
|
@ -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}}",
|
||||
|
|
Loading…
Reference in a new issue