mirror of
https://github.com/immich-app/immich.git
synced 2025-12-07 05:11:00 -08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67453d18ff | ||
|
|
792a87e407 | ||
|
|
6da50626e1 | ||
|
|
6239b3b309 | ||
|
|
b9bc621e2a | ||
|
|
e10bbfa933 | ||
|
|
2dd301e292 |
@@ -1,17 +0,0 @@
|
||||
# Deployment checklist for iOS/Android/Server
|
||||
|
||||
[ ] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
|
||||
|
||||
[ ] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
|
||||
|
||||
[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
|
||||
|
||||
[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.dev.yml) for `immich_server` service
|
||||
|
||||
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
|
||||
|
||||
[ ] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
|
||||
|
||||
[ ] Add changelog to [Android Fastlane F-droid folder](/mobile/android/fastlane/metadata/android/en-US/changelogs)
|
||||
|
||||
All of the version should be the same.
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: ghcr.io/immich-app/immich-server:release
|
||||
entrypoint: [ "/bin/sh", "./start-server.sh" ]
|
||||
entrypoint: ["/bin/sh", "./start-server.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
immich-microservices:
|
||||
container_name: immich_microservices
|
||||
image: ghcr.io/immich-app/immich-server:release
|
||||
entrypoint: [ "/bin/sh", "./start-microservices.sh" ]
|
||||
entrypoint: ["/bin/sh", "./start-microservices.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
immich-web:
|
||||
container_name: immich_web
|
||||
image: ghcr.io/immich-app/immich-web:release
|
||||
entrypoint: [ "/bin/sh", "./entrypoint.sh" ]
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
@@ -63,6 +63,7 @@ services:
|
||||
driver: none
|
||||
volumes:
|
||||
- tsdata:/data
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
|
||||
22
docs/docs/administration/reverse-proxy.md
Normal file
22
docs/docs/administration/reverse-proxy.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Reverse Proxy
|
||||
|
||||
When deploying Immich it is important to understand that a reverse proxy is required in front of the server and web container. The reverse proxy acts as an intermediary between the user and container, forwarding requests to the correct container based on the URL path.
|
||||
|
||||
## Default Reverse Proxy
|
||||
|
||||
Immich provides a default nginx reverse proxy preconfigured to perform the correct routing and set the necessary headers for the server and web container to use. These headers are crucial to redirect to the correct URL and determine the client's IP address.
|
||||
|
||||
## Using a Different Reverse Proxy
|
||||
|
||||
While the reverse proxy provided by Immich works well for basic deployments, some users may want to use a different reverse proxy. Fortunately, Immich is flexible enough to accommodate different reverse proxies. Users can either:
|
||||
|
||||
1. Add another reverse proxy on top of Immich's reverse proxy
|
||||
2. Completely replace the default reverse proxy
|
||||
|
||||
## Adding a Custom Reverse Proxy
|
||||
|
||||
Users can deploy a custom reverse proxy that forwards requests to Immich's reverse proxy. This way, the new reverse proxy can handle TLS termination, load balancing, or other advanced features, while still delegating routing decisions to Immich's reverse proxy. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
|
||||
|
||||
## Replacing the Default Reverse Proxy
|
||||
|
||||
Replacing Immich's default reverse proxy is an advanced deployment and support may be limited. When replacing Immich's default proxy it is important to ensure that requests to `/api/*` are routed to the server container and all other requests to the web container. Additionally, the previously mentioned headers should be configured accordingly. You may find our [nginx configuration file](https://github.com/immich-app/immich/blob/main/nginx/templates/default.conf.template) a helpful reference.
|
||||
@@ -36,7 +36,7 @@ platform :android do
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 74,
|
||||
"android.injected.version.name" => "1.51.1",
|
||||
"android.injected.version.name" => "1.51.2",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.51.1"
|
||||
version_number: "1.51.2"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.51.1+74
|
||||
version: 1.51.2+74
|
||||
isar_version: &isar_version 3.0.5
|
||||
|
||||
environment:
|
||||
|
||||
@@ -3,6 +3,14 @@ map $http_upgrade $connection_upgrade {
|
||||
'' close;
|
||||
}
|
||||
|
||||
map $http_x_forwarded_proto $forwarded_protocol {
|
||||
default $scheme;
|
||||
|
||||
# Only allow the values 'http' and 'https' for the X-Forwarded-Proto header.
|
||||
http http;
|
||||
https https;
|
||||
}
|
||||
|
||||
upstream server {
|
||||
server ${IMMICH_SERVER_HOST};
|
||||
keepalive 2;
|
||||
@@ -43,13 +51,12 @@ server {
|
||||
proxy_force_ranges on;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_protocol;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
rewrite /api/(.*) /$1 break;
|
||||
|
||||
@@ -64,13 +71,12 @@ server {
|
||||
proxy_force_ranges on;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_protocol;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_pass ${IMMICH_WEB_SCHEME}web;
|
||||
}
|
||||
|
||||
@@ -279,17 +279,20 @@ export class AssetService {
|
||||
/**
|
||||
* Serve file viewer on the web
|
||||
*/
|
||||
if (query.isWeb) {
|
||||
if (query.isWeb && asset.mimeType != 'image/gif') {
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
});
|
||||
|
||||
if (!asset.resizePath) {
|
||||
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
|
||||
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
|
||||
}
|
||||
|
||||
if (await processETag(asset.resizePath, res, headers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
|
||||
fileReadStream = createReadStream(asset.resizePath);
|
||||
|
||||
@@ -299,7 +302,7 @@ export class AssetService {
|
||||
/**
|
||||
* Serve thumbnail image for both web and mobile app
|
||||
*/
|
||||
if (!query.isThumb && allowOriginalFile) {
|
||||
if ((!query.isThumb && allowOriginalFile) || (query.isWeb && asset.mimeType === 'image/gif')) {
|
||||
res.set({
|
||||
'Content-Type': asset.mimeType,
|
||||
});
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ServerInfoService } from './server-info.service';
|
||||
import { serverVersion } from '../../constants/server_version.constant';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { ServerPingResponse } from './response-dto/server-ping-response.dto';
|
||||
import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
|
||||
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
|
||||
import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
|
||||
@ApiTags('Server Info')
|
||||
@Controller('server-info')
|
||||
export class ServerInfoController {
|
||||
constructor(private readonly serverInfoService: ServerInfoService) {}
|
||||
|
||||
@Get()
|
||||
async getServerInfo(): Promise<ServerInfoResponseDto> {
|
||||
return await this.serverInfoService.getServerInfo();
|
||||
}
|
||||
|
||||
@Get('/ping')
|
||||
async pingServer(): Promise<ServerPingResponse> {
|
||||
return new ServerPingResponse('pong');
|
||||
}
|
||||
|
||||
@Get('/version')
|
||||
async getServerVersion(): Promise<ServerVersionReponseDto> {
|
||||
return serverVersion;
|
||||
}
|
||||
|
||||
@Authenticated({ admin: true })
|
||||
@Get('/stats')
|
||||
async getStats(): Promise<ServerStatsResponseDto> {
|
||||
return await this.serverInfoService.getStats();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ServerInfoService } from './server-info.service';
|
||||
import { ServerInfoController } from './server-info.controller';
|
||||
import { UserEntity } from '@app/infra';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity])],
|
||||
controllers: [ServerInfoController],
|
||||
providers: [ServerInfoService],
|
||||
})
|
||||
export class ServerInfoModule {}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
|
||||
import diskusage from 'diskusage';
|
||||
import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
|
||||
import { UsageByUserDto } from './response-dto/usage-by-user-response.dto';
|
||||
import { UserEntity } from '@app/infra';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { asHumanReadable } from '../../utils/human-readable.util';
|
||||
|
||||
@Injectable()
|
||||
export class ServerInfoService {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity)
|
||||
private userRepository: Repository<UserEntity>,
|
||||
) {}
|
||||
|
||||
async getServerInfo(): Promise<ServerInfoResponseDto> {
|
||||
const diskInfo = await diskusage.check(APP_UPLOAD_LOCATION);
|
||||
|
||||
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
|
||||
|
||||
const serverInfo = new ServerInfoResponseDto();
|
||||
serverInfo.diskAvailable = asHumanReadable(diskInfo.available);
|
||||
serverInfo.diskSize = asHumanReadable(diskInfo.total);
|
||||
serverInfo.diskUse = asHumanReadable(diskInfo.total - diskInfo.free);
|
||||
serverInfo.diskAvailableRaw = diskInfo.available;
|
||||
serverInfo.diskSizeRaw = diskInfo.total;
|
||||
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
|
||||
serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
|
||||
return serverInfo;
|
||||
}
|
||||
|
||||
async getStats(): Promise<ServerStatsResponseDto> {
|
||||
type UserStatsQueryResponse = {
|
||||
userId: string;
|
||||
userFirstName: string;
|
||||
userLastName: string;
|
||||
photos: string;
|
||||
videos: string;
|
||||
usage: string;
|
||||
};
|
||||
|
||||
const userStatsQueryResponse: UserStatsQueryResponse[] = await this.userRepository
|
||||
.createQueryBuilder('users')
|
||||
.select('users.id', 'userId')
|
||||
.addSelect('users.firstName', 'userFirstName')
|
||||
.addSelect('users.lastName', 'userLastName')
|
||||
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
|
||||
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
|
||||
.addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
|
||||
.leftJoin('users.assets', 'assets')
|
||||
.leftJoin('assets.exifInfo', 'exif')
|
||||
.groupBy('users.id')
|
||||
.orderBy('users.createdAt', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
const usageByUser = userStatsQueryResponse.map((userStats) => {
|
||||
const usage = new UsageByUserDto();
|
||||
usage.userId = userStats.userId;
|
||||
usage.userFirstName = userStats.userFirstName;
|
||||
usage.userLastName = userStats.userLastName;
|
||||
usage.photos = Number(userStats.photos);
|
||||
usage.videos = Number(userStats.videos);
|
||||
usage.usage = Number(userStats.usage);
|
||||
|
||||
return usage;
|
||||
});
|
||||
|
||||
const serverStats = new ServerStatsResponseDto();
|
||||
usageByUser.forEach((user) => {
|
||||
serverStats.photos += user.photos;
|
||||
serverStats.videos += user.videos;
|
||||
serverStats.usage += user.usage;
|
||||
});
|
||||
serverStats.usageByUser = usageByUser;
|
||||
|
||||
return serverStats;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { immichAppConfig } from '@app/common/config';
|
||||
import { Module, OnModuleInit } from '@nestjs/common';
|
||||
import { AssetModule } from './api-v1/asset/asset.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
import { AlbumModule } from './api-v1/album/album.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
JobController,
|
||||
OAuthController,
|
||||
SearchController,
|
||||
ServerInfoController,
|
||||
ShareController,
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
@@ -34,8 +34,6 @@ import { AuthGuard } from './middlewares/auth.guard';
|
||||
|
||||
AssetModule,
|
||||
|
||||
ServerInfoModule,
|
||||
|
||||
AlbumModule,
|
||||
|
||||
ScheduleModule.forRoot(),
|
||||
@@ -52,6 +50,7 @@ import { AuthGuard } from './middlewares/auth.guard';
|
||||
JobController,
|
||||
OAuthController,
|
||||
SearchController,
|
||||
ServerInfoController,
|
||||
ShareController,
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||
import { APP_UPLOAD_LOCATION } from '@app/domain/domain.constant';
|
||||
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||
import { APP_UPLOAD_LOCATION } from '@app/domain/domain.constant';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
import { Request } from 'express';
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './device-info.controller';
|
||||
export * from './job.controller';
|
||||
export * from './oauth.controller';
|
||||
export * from './search.controller';
|
||||
export * from './server-info.controller';
|
||||
export * from './share.controller';
|
||||
export * from './system-config.controller';
|
||||
export * from './user.controller';
|
||||
|
||||
37
server/apps/immich/src/controllers/server-info.controller.ts
Normal file
37
server/apps/immich/src/controllers/server-info.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
ServerInfoResponseDto,
|
||||
ServerInfoService,
|
||||
ServerPingResponse,
|
||||
ServerStatsResponseDto,
|
||||
ServerVersionReponseDto,
|
||||
} from '@app/domain';
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||
|
||||
@ApiTags('Server Info')
|
||||
@Controller('server-info')
|
||||
export class ServerInfoController {
|
||||
constructor(private readonly service: ServerInfoService) {}
|
||||
|
||||
@Get()
|
||||
getServerInfo(): Promise<ServerInfoResponseDto> {
|
||||
return this.service.getInfo();
|
||||
}
|
||||
|
||||
@Get('/ping')
|
||||
pingServer(): ServerPingResponse {
|
||||
return this.service.ping();
|
||||
}
|
||||
|
||||
@Get('/version')
|
||||
getServerVersion(): ServerVersionReponseDto {
|
||||
return this.service.getVersion();
|
||||
}
|
||||
|
||||
@Authenticated({ admin: true })
|
||||
@Get('/stats')
|
||||
getStats(): Promise<ServerStatsResponseDto> {
|
||||
return this.service.getStats();
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,11 @@ import cookieParser from 'cookie-parser';
|
||||
import { writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { SERVER_VERSION } from './constants/server_version.constant';
|
||||
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
|
||||
import { json } from 'body-parser';
|
||||
import { patchOpenAPI } from './utils/patch-open-api.util';
|
||||
import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common';
|
||||
import { IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain';
|
||||
import { SERVER_VERSION, IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain';
|
||||
|
||||
const logger = new Logger('ImmichServer');
|
||||
|
||||
@@ -20,7 +19,7 @@ async function bootstrap() {
|
||||
logger: getLogLevels(),
|
||||
});
|
||||
|
||||
app.set('trust proxy');
|
||||
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
|
||||
app.set('etag', 'strong');
|
||||
app.use(cookieParser());
|
||||
app.use(json({ limit: '10mb' }));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AssetEntity } from '@app/infra';
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
|
||||
import archiver from 'archiver';
|
||||
import { extname } from 'path';
|
||||
import { asHumanReadable, HumanReadableSize } from '../../utils/human-readable.util';
|
||||
import { asHumanReadable, HumanReadableSize } from '@app/domain';
|
||||
|
||||
export interface DownloadArchive {
|
||||
stream: StreamableFile;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { SERVER_VERSION } from 'apps/immich/src/constants/server_version.constant';
|
||||
import { SERVER_VERSION } from '@app/domain';
|
||||
import { getLogLevels } from '@app/common';
|
||||
import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware';
|
||||
import { MicroservicesModule } from './microservices.module';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||
import { AssetEntity, AssetType } from '@app/infra';
|
||||
import {
|
||||
APP_UPLOAD_LOCATION,
|
||||
IAssetJob,
|
||||
IAssetRepository,
|
||||
IBaseJob,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
SystemConfigService,
|
||||
WithoutProperty,
|
||||
} from '@app/domain';
|
||||
import { AssetEntity, AssetType } from '@app/infra';
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { Job } from 'bull';
|
||||
|
||||
@@ -834,6 +834,102 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/server-info": {
|
||||
"get": {
|
||||
"operationId": "getServerInfo",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServerInfoResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Server Info"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/server-info/ping": {
|
||||
"get": {
|
||||
"operationId": "pingServer",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServerPingResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Server Info"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/server-info/version": {
|
||||
"get": {
|
||||
"operationId": "getServerVersion",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServerVersionReponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Server Info"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/server-info/stats": {
|
||||
"get": {
|
||||
"operationId": "getStats",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServerStatsResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Server Info"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/share": {
|
||||
"get": {
|
||||
"operationId": "getAllSharedLinks",
|
||||
@@ -3270,108 +3366,12 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/server-info": {
|
||||
"get": {
|
||||
"operationId": "getServerInfo",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServerInfoResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Server Info"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/server-info/ping": {
|
||||
"get": {
|
||||
"operationId": "pingServer",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServerPingResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Server Info"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/server-info/version": {
|
||||
"get": {
|
||||
"operationId": "getServerVersion",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServerVersionReponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Server Info"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/server-info/stats": {
|
||||
"get": {
|
||||
"operationId": "getStats",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServerStatsResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Server Info"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.51.1",
|
||||
"version": "1.51.2",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
@@ -4330,6 +4330,148 @@
|
||||
"items"
|
||||
]
|
||||
},
|
||||
"ServerInfoResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"diskSizeRaw": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"diskUseRaw": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"diskAvailableRaw": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"diskUsagePercentage": {
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"diskSize": {
|
||||
"type": "string"
|
||||
},
|
||||
"diskUse": {
|
||||
"type": "string"
|
||||
},
|
||||
"diskAvailable": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"diskSizeRaw",
|
||||
"diskUseRaw",
|
||||
"diskAvailableRaw",
|
||||
"diskUsagePercentage",
|
||||
"diskSize",
|
||||
"diskUse",
|
||||
"diskAvailable"
|
||||
]
|
||||
},
|
||||
"ServerPingResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"res": {
|
||||
"type": "string",
|
||||
"readOnly": true,
|
||||
"example": "pong"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"res"
|
||||
]
|
||||
},
|
||||
"ServerVersionReponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"major": {
|
||||
"type": "integer"
|
||||
},
|
||||
"minor": {
|
||||
"type": "integer"
|
||||
},
|
||||
"patch": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"major",
|
||||
"minor",
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
"UsageByUserDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userId": {
|
||||
"type": "string"
|
||||
},
|
||||
"userFirstName": {
|
||||
"type": "string"
|
||||
},
|
||||
"userLastName": {
|
||||
"type": "string"
|
||||
},
|
||||
"photos": {
|
||||
"type": "integer"
|
||||
},
|
||||
"videos": {
|
||||
"type": "integer"
|
||||
},
|
||||
"usage": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"userId",
|
||||
"userFirstName",
|
||||
"userLastName",
|
||||
"photos",
|
||||
"videos",
|
||||
"usage"
|
||||
]
|
||||
},
|
||||
"ServerStatsResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"photos": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
},
|
||||
"videos": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
},
|
||||
"usage": {
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"format": "int64"
|
||||
},
|
||||
"usageByUser": {
|
||||
"default": [],
|
||||
"title": "Array of usage for each user",
|
||||
"example": [
|
||||
{
|
||||
"photos": 1,
|
||||
"videos": 1,
|
||||
"diskUsageRaw": 1
|
||||
}
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UsageByUserDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"photos",
|
||||
"videos",
|
||||
"usage",
|
||||
"usageByUser"
|
||||
]
|
||||
},
|
||||
"SharedLinkType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -5271,148 +5413,6 @@
|
||||
"required": [
|
||||
"albumId"
|
||||
]
|
||||
},
|
||||
"ServerInfoResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"diskSizeRaw": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"diskUseRaw": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"diskAvailableRaw": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"diskUsagePercentage": {
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"diskSize": {
|
||||
"type": "string"
|
||||
},
|
||||
"diskUse": {
|
||||
"type": "string"
|
||||
},
|
||||
"diskAvailable": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"diskSizeRaw",
|
||||
"diskUseRaw",
|
||||
"diskAvailableRaw",
|
||||
"diskUsagePercentage",
|
||||
"diskSize",
|
||||
"diskUse",
|
||||
"diskAvailable"
|
||||
]
|
||||
},
|
||||
"ServerPingResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"res": {
|
||||
"type": "string",
|
||||
"readOnly": true,
|
||||
"example": "pong"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"res"
|
||||
]
|
||||
},
|
||||
"ServerVersionReponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"major": {
|
||||
"type": "integer"
|
||||
},
|
||||
"minor": {
|
||||
"type": "integer"
|
||||
},
|
||||
"patch": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"major",
|
||||
"minor",
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
"UsageByUserDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userId": {
|
||||
"type": "string"
|
||||
},
|
||||
"userFirstName": {
|
||||
"type": "string"
|
||||
},
|
||||
"userLastName": {
|
||||
"type": "string"
|
||||
},
|
||||
"photos": {
|
||||
"type": "integer"
|
||||
},
|
||||
"videos": {
|
||||
"type": "integer"
|
||||
},
|
||||
"usage": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"userId",
|
||||
"userFirstName",
|
||||
"userLastName",
|
||||
"photos",
|
||||
"videos",
|
||||
"usage"
|
||||
]
|
||||
},
|
||||
"ServerStatsResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"photos": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
},
|
||||
"videos": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
},
|
||||
"usage": {
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"format": "int64"
|
||||
},
|
||||
"usageByUser": {
|
||||
"default": [],
|
||||
"title": "Array of usage for each user",
|
||||
"example": [
|
||||
{
|
||||
"photos": 1,
|
||||
"videos": 1,
|
||||
"diskUsageRaw": 1
|
||||
}
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UsageByUserDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"photos",
|
||||
"videos",
|
||||
"usage",
|
||||
"usageByUser"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export * from './upload_location.constant';
|
||||
|
||||
export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003';
|
||||
export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false';
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const APP_UPLOAD_LOCATION = './upload';
|
||||
@@ -1,4 +1,4 @@
|
||||
import pkg from 'package.json';
|
||||
import pkg from '../../../package.json';
|
||||
|
||||
const [major, minor, patch] = pkg.version.split('.');
|
||||
|
||||
@@ -15,3 +15,5 @@ export const serverVersion: IServerVersion = {
|
||||
};
|
||||
|
||||
export const SERVER_VERSION = `${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`;
|
||||
|
||||
export const APP_UPLOAD_LOCATION = './upload';
|
||||
@@ -7,6 +7,7 @@ import { JobService } from './job';
|
||||
import { MediaService } from './media';
|
||||
import { OAuthService } from './oauth';
|
||||
import { SearchService } from './search';
|
||||
import { ServerInfoService } from './server-info';
|
||||
import { ShareService } from './share';
|
||||
import { SmartInfoService } from './smart-info';
|
||||
import { StorageService } from './storage';
|
||||
@@ -22,6 +23,7 @@ const providers: Provider[] = [
|
||||
JobService,
|
||||
MediaService,
|
||||
OAuthService,
|
||||
ServerInfoService,
|
||||
SmartInfoService,
|
||||
StorageService,
|
||||
StorageTemplateService,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { basename, extname } from 'node:path';
|
||||
|
||||
export function getFileNameWithoutExtension(path: string): string {
|
||||
return basename(path, extname(path));
|
||||
}
|
||||
|
||||
const KiB = Math.pow(1024, 1);
|
||||
const MiB = Math.pow(1024, 2);
|
||||
const GiB = Math.pow(1024, 3);
|
||||
@@ -5,11 +5,14 @@ export * from './auth';
|
||||
export * from './communication';
|
||||
export * from './crypto';
|
||||
export * from './device-info';
|
||||
export * from './domain.constant';
|
||||
export * from './domain.module';
|
||||
export * from './domain.util';
|
||||
export * from './job';
|
||||
export * from './media';
|
||||
export * from './oauth';
|
||||
export * from './search';
|
||||
export * from './server-info';
|
||||
export * from './share';
|
||||
export * from './smart-info';
|
||||
export * from './storage';
|
||||
@@ -18,4 +21,3 @@ export * from './system-config';
|
||||
export * from './tag';
|
||||
export * from './user';
|
||||
export * from './user-token';
|
||||
export * from './util';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common';
|
||||
import { AssetType } from '@app/infra/db/entities';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { join } from 'path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { IAssetRepository, mapAsset, WithoutProperty } from '../asset';
|
||||
import { CommunicationEvent, ICommunicationRepository } from '../communication';
|
||||
import { APP_UPLOAD_LOCATION } from '../domain.constant';
|
||||
import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
|
||||
import { IStorageRepository } from '../storage';
|
||||
import { IMediaRepository } from './media.repository';
|
||||
|
||||
@@ -173,12 +173,23 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
|
||||
describe('handleIndexAssets', () => {
|
||||
it('should call done, even when there are no assets', async () => {
|
||||
assetMock.getAll.mockResolvedValue([]);
|
||||
|
||||
await sut.handleIndexAssets();
|
||||
|
||||
expect(searchMock.importAssets).toHaveBeenCalledWith([], true);
|
||||
});
|
||||
|
||||
it('should index all the assets', async () => {
|
||||
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
|
||||
|
||||
await sut.handleIndexAssets();
|
||||
|
||||
expect(searchMock.importAssets).toHaveBeenCalledWith([assetEntityStub.image], true);
|
||||
expect(searchMock.importAssets.mock.calls).toEqual([
|
||||
[[assetEntityStub.image], false],
|
||||
[[], true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should log an error', async () => {
|
||||
|
||||
@@ -148,12 +148,11 @@ export class SearchService {
|
||||
|
||||
const chunkSize = 1000;
|
||||
for (let i = 0; i < assets.length; i += chunkSize) {
|
||||
const end = i + chunkSize;
|
||||
const chunk = assets.slice(i, end);
|
||||
const done = end >= assets.length - 1;
|
||||
await this.searchRepository.importAssets(chunk, done);
|
||||
await this.searchRepository.importAssets(assets.slice(i, i + chunkSize), false);
|
||||
}
|
||||
|
||||
await this.searchRepository.importAssets([], true);
|
||||
|
||||
this.logger.debug('Finished re-indexing all assets');
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Unable to index all assets`, error?.stack);
|
||||
|
||||
2
server/libs/domain/src/server-info/index.ts
Normal file
2
server/libs/domain/src/server-info/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './response-dto';
|
||||
export * from './server-info.service';
|
||||
5
server/libs/domain/src/server-info/response-dto/index.ts
Normal file
5
server/libs/domain/src/server-info/response-dto/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './server-info-response.dto';
|
||||
export * from './server-ping-response.dto';
|
||||
export * from './server-stats-response.dto';
|
||||
export * from './server-version-response.dto';
|
||||
export * from './usage-by-user-response.dto';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IServerVersion } from 'apps/immich/src/constants/server_version.constant';
|
||||
import { IServerVersion } from '@app/domain';
|
||||
|
||||
export class ServerVersionReponseDto implements IServerVersion {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
209
server/libs/domain/src/server-info/server-info.service.spec.ts
Normal file
209
server/libs/domain/src/server-info/server-info.service.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { newStorageRepositoryMock, newUserRepositoryMock } from '../../test';
|
||||
import { serverVersion } from '../domain.constant';
|
||||
import { IStorageRepository } from '../storage';
|
||||
import { IUserRepository } from '../user';
|
||||
import { ServerInfoService } from './server-info.service';
|
||||
|
||||
describe(ServerInfoService.name, () => {
|
||||
let sut: ServerInfoService;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
storageMock = newStorageRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
|
||||
sut = new ServerInfoService(userMock, storageMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getInfo', () => {
|
||||
it('should return the disk space as B', async () => {
|
||||
storageMock.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 });
|
||||
|
||||
await expect(sut.getInfo()).resolves.toEqual({
|
||||
diskAvailable: '300 B',
|
||||
diskAvailableRaw: 300,
|
||||
diskSize: '500 B',
|
||||
diskSizeRaw: 500,
|
||||
diskUsagePercentage: 60,
|
||||
diskUse: '300 B',
|
||||
diskUseRaw: 300,
|
||||
});
|
||||
|
||||
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
|
||||
});
|
||||
|
||||
it('should return the disk space as KiB', async () => {
|
||||
storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 });
|
||||
|
||||
await expect(sut.getInfo()).resolves.toEqual({
|
||||
diskAvailable: '293.0 KiB',
|
||||
diskAvailableRaw: 300000,
|
||||
diskSize: '488.3 KiB',
|
||||
diskSizeRaw: 500000,
|
||||
diskUsagePercentage: 60,
|
||||
diskUse: '293.0 KiB',
|
||||
diskUseRaw: 300000,
|
||||
});
|
||||
|
||||
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
|
||||
});
|
||||
|
||||
it('should return the disk space as MiB', async () => {
|
||||
storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 });
|
||||
|
||||
await expect(sut.getInfo()).resolves.toEqual({
|
||||
diskAvailable: '286.1 MiB',
|
||||
diskAvailableRaw: 300000000,
|
||||
diskSize: '476.8 MiB',
|
||||
diskSizeRaw: 500000000,
|
||||
diskUsagePercentage: 60,
|
||||
diskUse: '286.1 MiB',
|
||||
diskUseRaw: 300000000,
|
||||
});
|
||||
|
||||
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
|
||||
});
|
||||
|
||||
it('should return the disk space as GiB', async () => {
|
||||
storageMock.checkDiskUsage.mockResolvedValue({
|
||||
free: 200_000_000_000,
|
||||
available: 300_000_000_000,
|
||||
total: 500_000_000_000,
|
||||
});
|
||||
|
||||
await expect(sut.getInfo()).resolves.toEqual({
|
||||
diskAvailable: '279.4 GiB',
|
||||
diskAvailableRaw: 300000000000,
|
||||
diskSize: '465.7 GiB',
|
||||
diskSizeRaw: 500000000000,
|
||||
diskUsagePercentage: 60,
|
||||
diskUse: '279.4 GiB',
|
||||
diskUseRaw: 300000000000,
|
||||
});
|
||||
|
||||
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
|
||||
});
|
||||
|
||||
it('should return the disk space as TiB', async () => {
|
||||
storageMock.checkDiskUsage.mockResolvedValue({
|
||||
free: 200_000_000_000_000,
|
||||
available: 300_000_000_000_000,
|
||||
total: 500_000_000_000_000,
|
||||
});
|
||||
|
||||
await expect(sut.getInfo()).resolves.toEqual({
|
||||
diskAvailable: '272.8 TiB',
|
||||
diskAvailableRaw: 300000000000000,
|
||||
diskSize: '454.7 TiB',
|
||||
diskSizeRaw: 500000000000000,
|
||||
diskUsagePercentage: 60,
|
||||
diskUse: '272.8 TiB',
|
||||
diskUseRaw: 300000000000000,
|
||||
});
|
||||
|
||||
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
|
||||
});
|
||||
|
||||
it('should return the disk space as PiB', async () => {
|
||||
storageMock.checkDiskUsage.mockResolvedValue({
|
||||
free: 200_000_000_000_000_000,
|
||||
available: 300_000_000_000_000_000,
|
||||
total: 500_000_000_000_000_000,
|
||||
});
|
||||
|
||||
await expect(sut.getInfo()).resolves.toEqual({
|
||||
diskAvailable: '266.5 PiB',
|
||||
diskAvailableRaw: 300000000000000000,
|
||||
diskSize: '444.1 PiB',
|
||||
diskSizeRaw: 500000000000000000,
|
||||
diskUsagePercentage: 60,
|
||||
diskUse: '266.5 PiB',
|
||||
diskUseRaw: 300000000000000000,
|
||||
});
|
||||
|
||||
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ping', () => {
|
||||
it('should respond with pong', () => {
|
||||
expect(sut.ping()).toEqual({ res: 'pong' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVersion', () => {
|
||||
it('should respond the server version', () => {
|
||||
expect(sut.getVersion()).toEqual(serverVersion);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('should total up usage by user', async () => {
|
||||
userMock.getUserStats.mockResolvedValue([
|
||||
{
|
||||
userId: 'user1',
|
||||
userFirstName: '1',
|
||||
userLastName: 'User',
|
||||
photos: 10,
|
||||
videos: 11,
|
||||
usage: 12345,
|
||||
},
|
||||
{
|
||||
userId: 'user2',
|
||||
userFirstName: '2',
|
||||
userLastName: 'User',
|
||||
photos: 10,
|
||||
videos: 20,
|
||||
usage: 123456,
|
||||
},
|
||||
{
|
||||
userId: 'user3',
|
||||
userFirstName: '3',
|
||||
userLastName: 'User',
|
||||
photos: 100,
|
||||
videos: 0,
|
||||
usage: 987654,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(sut.getStats()).resolves.toEqual({
|
||||
photos: 120,
|
||||
videos: 31,
|
||||
usage: 1123455,
|
||||
usageByUser: [
|
||||
{
|
||||
photos: 10,
|
||||
usage: 12345,
|
||||
userFirstName: '1',
|
||||
userId: 'user1',
|
||||
userLastName: 'User',
|
||||
videos: 11,
|
||||
},
|
||||
{
|
||||
photos: 10,
|
||||
usage: 123456,
|
||||
userFirstName: '2',
|
||||
userId: 'user2',
|
||||
userLastName: 'User',
|
||||
videos: 20,
|
||||
},
|
||||
{
|
||||
photos: 100,
|
||||
usage: 987654,
|
||||
userFirstName: '3',
|
||||
userId: 'user3',
|
||||
userLastName: 'User',
|
||||
videos: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(userMock.getUserStats).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
60
server/libs/domain/src/server-info/server-info.service.ts
Normal file
60
server/libs/domain/src/server-info/server-info.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { APP_UPLOAD_LOCATION, serverVersion } from '../domain.constant';
|
||||
import { asHumanReadable } from '../domain.util';
|
||||
import { IStorageRepository } from '../storage';
|
||||
import { IUserRepository, UserStatsQueryResponse } from '../user';
|
||||
import { ServerInfoResponseDto, ServerPingResponse, ServerStatsResponseDto, UsageByUserDto } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class ServerInfoService {
|
||||
constructor(
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
) {}
|
||||
|
||||
async getInfo(): Promise<ServerInfoResponseDto> {
|
||||
const diskInfo = await this.storageRepository.checkDiskUsage(APP_UPLOAD_LOCATION);
|
||||
|
||||
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
|
||||
|
||||
const serverInfo = new ServerInfoResponseDto();
|
||||
serverInfo.diskAvailable = asHumanReadable(diskInfo.available);
|
||||
serverInfo.diskSize = asHumanReadable(diskInfo.total);
|
||||
serverInfo.diskUse = asHumanReadable(diskInfo.total - diskInfo.free);
|
||||
serverInfo.diskAvailableRaw = diskInfo.available;
|
||||
serverInfo.diskSizeRaw = diskInfo.total;
|
||||
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
|
||||
serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
|
||||
return serverInfo;
|
||||
}
|
||||
|
||||
ping(): ServerPingResponse {
|
||||
return new ServerPingResponse('pong');
|
||||
}
|
||||
|
||||
getVersion() {
|
||||
return serverVersion;
|
||||
}
|
||||
|
||||
async getStats(): Promise<ServerStatsResponseDto> {
|
||||
const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
|
||||
const serverStats = new ServerStatsResponseDto();
|
||||
|
||||
for (const user of userStats) {
|
||||
const usage = new UsageByUserDto();
|
||||
usage.userId = user.userId;
|
||||
usage.userFirstName = user.userFirstName;
|
||||
usage.userLastName = user.userLastName;
|
||||
usage.photos = user.photos;
|
||||
usage.videos = user.videos;
|
||||
usage.usage = user.usage;
|
||||
|
||||
serverStats.photos += usage.photos;
|
||||
serverStats.videos += usage.videos;
|
||||
serverStats.usage += usage.usage;
|
||||
serverStats.usageByUser.push(usage);
|
||||
}
|
||||
|
||||
return serverStats;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common';
|
||||
import {
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
@@ -15,6 +14,7 @@ import handlebar from 'handlebars';
|
||||
import * as luxon from 'luxon';
|
||||
import path from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { APP_UPLOAD_LOCATION } from '../domain.constant';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
|
||||
export class StorageTemplateCore {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common';
|
||||
import { AssetEntity, SystemConfig } from '@app/infra/db/entities';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { IAssetRepository } from '../asset/asset.repository';
|
||||
import { APP_UPLOAD_LOCATION } from '../domain.constant';
|
||||
import { IStorageRepository } from '../storage/storage.repository';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { StorageTemplateCore } from './storage-template.core';
|
||||
|
||||
@@ -6,6 +6,12 @@ export interface ImmichReadStream {
|
||||
length: number;
|
||||
}
|
||||
|
||||
export interface DiskUsage {
|
||||
available: number;
|
||||
free: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const IStorageRepository = 'IStorageRepository';
|
||||
|
||||
export interface IStorageRepository {
|
||||
@@ -16,4 +22,5 @@ export interface IStorageRepository {
|
||||
moveFile(source: string, target: string): Promise<void>;
|
||||
checkFileExists(filepath: string): Promise<boolean>;
|
||||
mkdirSync(filepath: string): void;
|
||||
checkDiskUsage(folder: string): Promise<DiskUsage>;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,15 @@ export interface UserListFilter {
|
||||
excludeId?: string;
|
||||
}
|
||||
|
||||
export interface UserStatsQueryResponse {
|
||||
userId: string;
|
||||
userFirstName: string;
|
||||
userLastName: string;
|
||||
photos: number;
|
||||
videos: number;
|
||||
usage: number;
|
||||
}
|
||||
|
||||
export const IUserRepository = 'IUserRepository';
|
||||
|
||||
export interface IUserRepository {
|
||||
@@ -13,6 +22,7 @@ export interface IUserRepository {
|
||||
getByOAuthId(oauthId: string): Promise<UserEntity | null>;
|
||||
getDeletedUsers(): Promise<UserEntity[]>;
|
||||
getList(filter?: UserListFilter): Promise<UserEntity[]>;
|
||||
getUserStats(): Promise<UserStatsQueryResponse[]>;
|
||||
create(user: Partial<UserEntity>): Promise<UserEntity>;
|
||||
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
|
||||
delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;
|
||||
|
||||
@@ -3,12 +3,12 @@ import { BadRequestException, Inject, Injectable, Logger, NotFoundException } fr
|
||||
import { randomBytes } from 'crypto';
|
||||
import { ReadStream } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common';
|
||||
import { IAlbumRepository } from '../album/album.repository';
|
||||
import { IKeyRepository } from '../api-key/api-key.repository';
|
||||
import { IAssetRepository } from '../asset/asset.repository';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { APP_UPLOAD_LOCATION } from '../domain.constant';
|
||||
import { IJobRepository, IUserDeletionJob, JobName } from '../job';
|
||||
import { IStorageRepository } from '../storage/storage.repository';
|
||||
import { IUserTokenRepository } from '../user-token/user-token.repository';
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { basename, extname } from 'node:path';
|
||||
|
||||
export function getFileNameWithoutExtension(path: string): string {
|
||||
return basename(path, extname(path));
|
||||
}
|
||||
@@ -9,5 +9,6 @@ export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
|
||||
moveFile: jest.fn(),
|
||||
checkFileExists: jest.fn(),
|
||||
mkdirSync: jest.fn(),
|
||||
checkDiskUsage: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => {
|
||||
getAdmin: jest.fn(),
|
||||
getByEmail: jest.fn(),
|
||||
getByOAuthId: jest.fn(),
|
||||
getUserStats: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UserEntity } from '../entities';
|
||||
import { IUserRepository, UserListFilter } from '@app/domain';
|
||||
import { IUserRepository, UserListFilter, UserStatsQueryResponse } from '@app/domain';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
@@ -76,4 +76,28 @@ export class UserRepository implements IUserRepository {
|
||||
async restore(user: UserEntity): Promise<UserEntity> {
|
||||
return this.userRepository.recover(user);
|
||||
}
|
||||
|
||||
async getUserStats(): Promise<UserStatsQueryResponse[]> {
|
||||
const stats = await this.userRepository
|
||||
.createQueryBuilder('users')
|
||||
.select('users.id', 'userId')
|
||||
.addSelect('users.firstName', 'userFirstName')
|
||||
.addSelect('users.lastName', 'userLastName')
|
||||
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
|
||||
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
|
||||
.addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
|
||||
.leftJoin('users.assets', 'assets')
|
||||
.leftJoin('assets.exifInfo', 'exif')
|
||||
.groupBy('users.id')
|
||||
.orderBy('users.createdAt', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
for (const stat of stats) {
|
||||
stat.photos = Number(stat.photos);
|
||||
stat.videos = Number(stat.videos);
|
||||
stat.usage = Number(stat.usage);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ImmichReadStream, IStorageRepository } from '@app/domain';
|
||||
import { DiskUsage, ImmichReadStream, IStorageRepository } from '@app/domain';
|
||||
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import mv from 'mv';
|
||||
import { promisify } from 'node:util';
|
||||
import diskUsage from 'diskusage';
|
||||
import path from 'path';
|
||||
|
||||
const moveFile = promisify<string, string, mv.Options>(mv);
|
||||
@@ -66,4 +67,8 @@ export class FilesystemProvider implements IStorageRepository {
|
||||
mkdirSync(filepath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
checkDiskUsage(folder: string): Promise<DiskUsage> {
|
||||
return diskUsage.check(folder);
|
||||
}
|
||||
}
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.51.1",
|
||||
"version": "1.51.2",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich",
|
||||
"version": "1.50.1",
|
||||
"version": "1.51.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.51.1",
|
||||
"version": "1.51.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -129,7 +129,7 @@
|
||||
"rootDir": ".",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
"^.+\\.ts$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s",
|
||||
@@ -137,10 +137,6 @@
|
||||
],
|
||||
"coverageDirectory": "./coverage",
|
||||
"coverageThreshold": {
|
||||
"global": {
|
||||
"lines": 17,
|
||||
"statements": 17
|
||||
},
|
||||
"./libs/domain/": {
|
||||
"branches": 80,
|
||||
"functions": 85,
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
"paths": {
|
||||
"@app/common": ["libs/common/src"],
|
||||
"@app/common/*": ["libs/common/src/*"],
|
||||
"@app/storage": ["libs/storage/src"],
|
||||
"@app/storage/*": ["libs/storage/src/*"],
|
||||
"@app/infra": ["libs/infra/src"],
|
||||
"@app/infra/*": ["libs/infra/src/*"],
|
||||
"@app/domain": ["libs/domain/src"],
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
export PUBLIC_IMMICH_SERVER_URL=$IMMICH_SERVER_URL
|
||||
export PUBLIC_IMMICH_API_URL_EXTERNAL=$IMMICH_API_URL_EXTERNAL
|
||||
|
||||
export PROTOCOL_HEADER=X-Forwarded-Proto
|
||||
|
||||
if [ "$(id -u)" -eq 0 ] && [ -n "$PUID" ] && [ -n "$PGID" ]; then
|
||||
exec setpriv --reuid "$PUID" --regid "$PGID" --clear-groups node /usr/src/app/build/index.js
|
||||
else
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
api.systemConfigApi.getStorageTemplateOptions().then((res) => res.data)
|
||||
]);
|
||||
|
||||
selectedPreset = templateOptions.presetOptions[0];
|
||||
selectedPreset = savedConfig.template;
|
||||
}
|
||||
|
||||
const getSupportDateTimeFormat = async () => {
|
||||
|
||||
Reference in New Issue
Block a user