1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-21 18:16:55 +02:00

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 515 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');

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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}}",