1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(web,server)!: runtime log level (#5672)

* feat: change log level at runtime

* chore: open api

* chore: prefer env over runtime

* chore: remove default env value
This commit is contained in:
Jason Rasmussen 2023-12-14 11:55:40 -05:00 committed by GitHub
parent f2270ad757
commit 9768931275
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 459 additions and 116 deletions

View File

@ -2175,6 +2175,24 @@ export const LibraryType = {
export type LibraryType = typeof LibraryType[keyof typeof LibraryType];
/**
*
* @export
* @enum {string}
*/
export const LogLevel = {
Verbose: 'verbose',
Debug: 'debug',
Log: 'log',
Warn: 'warn',
Error: 'error',
Fatal: 'fatal'
} as const;
export type LogLevel = typeof LogLevel[keyof typeof LogLevel];
/**
*
* @export
@ -3577,6 +3595,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto
*/
'library': SystemConfigLibraryDto;
/**
*
* @type {SystemConfigLoggingDto}
* @memberof SystemConfigDto
*/
'logging': SystemConfigLoggingDto;
/**
*
* @type {SystemConfigMachineLearningDto}
@ -3860,6 +3884,27 @@ export interface SystemConfigLibraryScanDto {
*/
'enabled': boolean;
}
/**
*
* @export
* @interface SystemConfigLoggingDto
*/
export interface SystemConfigLoggingDto {
/**
*
* @type {boolean}
* @memberof SystemConfigLoggingDto
*/
'enabled': boolean;
/**
*
* @type {LogLevel}
* @memberof SystemConfigLoggingDto
*/
'level': LogLevel;
}
/**
*
* @export

View File

@ -82,6 +82,7 @@ doc/LibraryApi.md
doc/LibraryResponseDto.md
doc/LibraryStatsResponseDto.md
doc/LibraryType.md
doc/LogLevel.md
doc/LoginCredentialDto.md
doc/LoginResponseDto.md
doc/LogoutResponseDto.md
@ -142,6 +143,7 @@ doc/SystemConfigFFmpegDto.md
doc/SystemConfigJobDto.md
doc/SystemConfigLibraryDto.md
doc/SystemConfigLibraryScanDto.md
doc/SystemConfigLoggingDto.md
doc/SystemConfigMachineLearningDto.md
doc/SystemConfigMapDto.md
doc/SystemConfigNewVersionCheckDto.md
@ -274,6 +276,7 @@ lib/model/job_status_dto.dart
lib/model/library_response_dto.dart
lib/model/library_stats_response_dto.dart
lib/model/library_type.dart
lib/model/log_level.dart
lib/model/login_credential_dto.dart
lib/model/login_response_dto.dart
lib/model/logout_response_dto.dart
@ -327,6 +330,7 @@ lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_job_dto.dart
lib/model/system_config_library_dto.dart
lib/model/system_config_library_scan_dto.dart
lib/model/system_config_logging_dto.dart
lib/model/system_config_machine_learning_dto.dart
lib/model/system_config_map_dto.dart
lib/model/system_config_new_version_check_dto.dart
@ -439,6 +443,7 @@ test/library_api_test.dart
test/library_response_dto_test.dart
test/library_stats_response_dto_test.dart
test/library_type_test.dart
test/log_level_test.dart
test/login_credential_dto_test.dart
test/login_response_dto_test.dart
test/logout_response_dto_test.dart
@ -499,6 +504,7 @@ test/system_config_f_fmpeg_dto_test.dart
test/system_config_job_dto_test.dart
test/system_config_library_dto_test.dart
test/system_config_library_scan_dto_test.dart
test/system_config_logging_dto_test.dart
test/system_config_machine_learning_dto_test.dart
test/system_config_map_dto_test.dart
test/system_config_new_version_check_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

BIN
mobile/openapi/doc/LogLevel.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/model/log_level.dart generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/test/log_level_test.dart generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -7949,6 +7949,17 @@
],
"type": "string"
},
"LogLevel": {
"enum": [
"verbose",
"debug",
"log",
"warn",
"error",
"fatal"
],
"type": "string"
},
"LoginCredentialDto": {
"properties": {
"email": {
@ -9039,6 +9050,9 @@
"library": {
"$ref": "#/components/schemas/SystemConfigLibraryDto"
},
"logging": {
"$ref": "#/components/schemas/SystemConfigLoggingDto"
},
"machineLearning": {
"$ref": "#/components/schemas/SystemConfigMachineLearningDto"
},
@ -9072,6 +9086,7 @@
},
"required": [
"ffmpeg",
"logging",
"machineLearning",
"map",
"newVersionCheck",
@ -9243,6 +9258,21 @@
],
"type": "object"
},
"SystemConfigLoggingDto": {
"properties": {
"enabled": {
"type": "boolean"
},
"level": {
"$ref": "#/components/schemas/LogLevel"
}
},
"required": [
"level",
"enabled"
],
"type": "object"
},
"SystemConfigMachineLearningDto": {
"properties": {
"classification": {

View File

@ -153,9 +153,6 @@
"statements": 90
}
},
"setupFilesAfterEnv": [
"<rootDir>/test/setup.ts"
],
"testEnvironment": "node",
"moduleNameMapper": {
"^@test(|/.*)$": "<rootDir>/test/$1",

View File

@ -1,5 +1,6 @@
import { AssetEntity, LibraryType } from '@app/infra/entities';
import { BadRequestException, Inject, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, Inject } from '@nestjs/common';
import _ from 'lodash';
import { DateTime, Duration } from 'luxon';
import { extname } from 'path';
@ -75,7 +76,7 @@ export interface UploadFile {
}
export class AssetService {
private logger = new Logger(AssetService.name);
private logger = new ImmichLogger(AssetService.name);
private access: AccessCore;
private configCore: SystemConfigCore;

View File

@ -1,5 +1,6 @@
import { AssetPathType, DatabaseAction, PersonPathType, UserPathType } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { AccessCore, Permission } from '../access';
@ -29,7 +30,7 @@ import {
@Injectable()
export class AuditService {
private access: AccessCore;
private logger = new Logger(AuditService.name);
private logger = new ImmichLogger(AuditService.name);
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,

View File

@ -1,10 +1,10 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import {
BadRequestException,
Inject,
Injectable,
InternalServerErrorException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import cookieParser from 'cookie';
@ -68,7 +68,7 @@ interface OAuthProfile extends UserinfoResponse {
export class AuthService {
private access: AccessCore;
private configCore: SystemConfigCore;
private logger = new Logger(AuthService.name);
private logger = new ImmichLogger(AuthService.name);
private userCore: UserCore;
constructor(

View File

@ -1,5 +1,5 @@
// TODO: remove nestjs references from domain
import { LogLevel } from '@nestjs/common';
import { LogLevel } from '@app/infra/entities';
import { ConfigModuleOptions } from '@nestjs/config';
import Joi from 'joi';
@ -18,19 +18,11 @@ export const immichAppConfig: ConfigModuleOptions = {
DB_PASSWORD: WHEN_DB_URL_SET,
DB_DATABASE_NAME: WHEN_DB_URL_SET,
DB_URL: Joi.string().optional(),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'),
LOG_LEVEL: Joi.string()
.optional()
.valid(...Object.values(LogLevel)),
MACHINE_LEARNING_PORT: Joi.number().optional(),
MICROSERVICES_PORT: Joi.number().optional(),
SERVER_PORT: Joi.number().optional(),
}),
};
export function getLogLevels() {
const LOG_LEVELS: LogLevel[] = ['verbose', 'debug', 'log', 'warn', 'error'];
let logLevel = process.env.LOG_LEVEL || 'log';
if (logLevel === 'simple') {
logLevel = 'log';
}
const logLevelIndex = LOG_LEVELS.indexOf(logLevel as LogLevel);
return logLevelIndex === -1 ? [] : LOG_LEVELS.slice(logLevelIndex);
}

View File

@ -1,3 +1,4 @@
import { ImmichLogger } from '@app/infra/logger';
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
import { ActivityService } from './activity';
import { AlbumService } from './album';
@ -43,6 +44,7 @@ const providers: Provider[] = [
SystemConfigService,
TagService,
UserService,
ImmichLogger,
{
provide: INITIAL_SYSTEM_CONFIG,
inject: [SystemConfigService],

View File

@ -1,5 +1,6 @@
import { AssetType } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { mapAsset } from '../asset';
import {
ClientEvent,
@ -18,7 +19,7 @@ import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from './job.dto'
@Injectable()
export class JobService {
private logger = new Logger(JobService.name);
private logger = new ImmichLogger(JobService.name);
private configCore: SystemConfigCore;
constructor(

View File

@ -1,5 +1,5 @@
import { AssetType, LibraryType } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { R_OK } from 'node:constants';
import { Stats } from 'node:fs';
import path from 'node:path';
@ -10,6 +10,7 @@ import { mimeTypes } from '../domain.constant';
import { usePagination, validateCronExpression } from '../domain.util';
import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import { ImmichLogger } from '@app/infra/logger';
import {
IAccessRepository,
IAssetRepository,
@ -33,7 +34,7 @@ import {
@Injectable()
export class LibraryService {
readonly logger = new Logger(LibraryService.name);
readonly logger = new ImmichLogger(LibraryService.name);
private access: AccessCore;
private configCore: SystemConfigCore;

View File

@ -7,7 +7,8 @@ import {
TranscodePolicy,
VideoCodec,
} from '@app/infra/entities';
import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common';
import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
import {
@ -39,7 +40,7 @@ import {
@Injectable()
export class MediaService {
private logger = new Logger(MediaService.name);
private logger = new ImmichLogger(MediaService.name);
private configCore: SystemConfigCore;
private storageCore: StorageCore;

View File

@ -1,5 +1,6 @@
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { ExifDateTime, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import { constants } from 'fs/promises';
@ -91,7 +92,7 @@ const validate = <T>(value: T): NonNullable<T> | null => {
@Injectable()
export class MetadataService {
private logger = new Logger(MetadataService.name);
private logger = new ImmichLogger(MetadataService.name);
private storageCore: StorageCore;
private configCore: SystemConfigCore;
private subscription: Subscription | null = null;

View File

@ -1,6 +1,7 @@
import { PersonEntity } from '@app/infra/entities';
import { PersonPathType } from '@app/infra/entities/move.entity';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { AccessCore, Permission } from '../access';
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
import { AuthDto } from '../auth';
@ -45,7 +46,7 @@ export class PersonService {
private access: AccessCore;
private configCore: SystemConfigCore;
private storageCore: StorageCore;
readonly logger = new Logger(PersonService.name);
readonly logger = new ImmichLogger(PersonService.name);
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,

View File

@ -1,5 +1,6 @@
import { AssetEntity } from '@app/infra/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { AssetResponseDto, mapAsset } from '../asset';
import { AuthDto } from '../auth';
import { PersonResponseDto } from '../person';
@ -18,7 +19,7 @@ import { SearchResponseDto } from './response-dto';
@Injectable()
export class SearchService {
private logger = new Logger(SearchService.name);
private logger = new ImmichLogger(SearchService.name);
private configCore: SystemConfigCore;
constructor(

View File

@ -1,4 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { ServerVersion, isDev, mimeTypes, serverVersion } from '../domain.constant';
import { asHumanReadable } from '../domain.util';
@ -25,7 +26,7 @@ import {
@Injectable()
export class ServerInfoService {
private logger = new Logger(ServerInfoService.name);
private logger = new ImmichLogger(ServerInfoService.name);
private configCore: SystemConfigCore;
private releaseVersion = serverVersion;
private releaseVersionCheckedAt: DateTime | null = null;

View File

@ -1,4 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { setTimeout } from 'timers/promises';
import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
@ -15,7 +16,7 @@ import { SystemConfigCore } from '../system-config';
@Injectable()
export class SmartInfoService {
private configCore: SystemConfigCore;
private logger = new Logger(SmartInfoService.name);
private logger = new ImmichLogger(SmartInfoService.name);
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,

View File

@ -1,5 +1,6 @@
import { AssetEntity, AssetPathType, AssetType, SystemConfig } from '@app/infra/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import handlebar from 'handlebars';
import * as luxon from 'luxon';
import path from 'node:path';
@ -42,7 +43,7 @@ interface RenderMetadata {
@Injectable()
export class StorageTemplateService {
private logger = new Logger(StorageTemplateService.name);
private logger = new ImmichLogger(StorageTemplateService.name);
private configCore: SystemConfigCore;
private storageCore: StorageCore;
private template: {

View File

@ -1,5 +1,5 @@
import { AssetEntity, AssetPathType, PathType, PersonEntity, PersonPathType } from '@app/infra/entities';
import { Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from '../domain.constant';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
@ -24,7 +24,7 @@ type GeneratedAssetPath = AssetPathType.JPEG_THUMBNAIL | AssetPathType.WEBP_THUM
let instance: StorageCore | null;
export class StorageCore {
private logger = new Logger(StorageCore.name);
private logger = new ImmichLogger(StorageCore.name);
private constructor(
private assetRepository: IAssetRepository,

View File

@ -1,11 +1,12 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { IDeleteFilesJob } from '../job';
import { IStorageRepository } from '../repositories';
import { StorageCore, StorageFolder } from './storage.core';
@Injectable()
export class StorageService {
private logger = new Logger(StorageService.name);
private logger = new ImmichLogger(StorageService.name);
constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {}

View File

@ -0,0 +1,12 @@
import { LogLevel } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsEnum } from 'class-validator';
export class SystemConfigLoggingDto {
@IsBoolean()
enabled!: boolean;
@ApiProperty({ enum: LogLevel, enumName: 'LogLevel' })
@IsEnum(LogLevel)
level!: LogLevel;
}

View File

@ -4,6 +4,7 @@ import { IsObject, ValidateNested } from 'class-validator';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigJobDto } from './system-config-job.dto';
import { SystemConfigLibraryDto } from './system-config-library.dto';
import { SystemConfigLoggingDto } from './system-config-logging.dto';
import { SystemConfigMachineLearningDto } from './system-config-machine-learning.dto';
import { SystemConfigMapDto } from './system-config-map.dto';
import { SystemConfigNewVersionCheckDto } from './system-config-new-version-check.dto';
@ -21,6 +22,11 @@ export class SystemConfigDto implements SystemConfig {
@IsObject()
ffmpeg!: SystemConfigFFmpegDto;
@Type(() => SystemConfigLoggingDto)
@ValidateNested()
@IsObject()
logging!: SystemConfigLoggingDto;
@Type(() => SystemConfigMachineLearningDto)
@ValidateNested()
@IsObject()

View File

@ -2,6 +2,7 @@ import {
AudioCodec,
Colorspace,
CQMode,
LogLevel,
SystemConfig,
SystemConfigEntity,
SystemConfigKey,
@ -11,7 +12,8 @@ import {
TranscodePolicy,
VideoCodec,
} from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { CronExpression } from '@nestjs/schedule';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
@ -21,7 +23,7 @@ import { QueueName } from '../job/job.constants';
import { ISystemConfigRepository } from '../repositories';
import { SystemConfigDto } from './dto';
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise<void>;
export const defaults = Object.freeze<SystemConfig>({
ffmpeg: {
@ -57,6 +59,10 @@ export const defaults = Object.freeze<SystemConfig>({
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
},
logging: {
enabled: true,
level: LogLevel.LOG,
},
machineLearning: {
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
url: process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003',
@ -149,7 +155,7 @@ let instance: SystemConfigCore | null;
@Injectable()
export class SystemConfigCore {
private logger = new Logger(SystemConfigCore.name);
private logger = new ImmichLogger(SystemConfigCore.name);
private validators: SystemConfigValidator[] = [];
private configCache: SystemConfigEntity<SystemConfigValue>[] | null = null;
@ -253,14 +259,16 @@ export class SystemConfigCore {
return config;
}
public async updateConfig(config: SystemConfig): Promise<SystemConfig> {
public async updateConfig(newConfig: SystemConfig): Promise<SystemConfig> {
if (await this.hasFeature(FeatureFlag.CONFIG_FILE)) {
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
}
const oldConfig = await this.getConfig();
try {
for (const validator of this.validators) {
await validator(config);
await validator(newConfig, oldConfig);
}
} catch (e) {
this.logger.warn(`Unable to save system config due to a validation error: ${e}`);
@ -272,9 +280,9 @@ export class SystemConfigCore {
for (const key of Object.values(SystemConfigKey)) {
// get via dot notation
const item = { key, value: _.get(config, key) as SystemConfigValue };
const item = { key, value: _.get(newConfig, key) as SystemConfigValue };
const defaultValue = _.get(defaults, key);
const isMissing = !_.has(config, key);
const isMissing = !_.has(newConfig, key);
if (
isMissing ||
@ -298,11 +306,11 @@ export class SystemConfigCore {
await this.repository.deleteKeys(deletes.map((item) => item.key));
}
const newConfig = await this.getConfig();
const config = await this.getConfig();
this.config$.next(newConfig);
this.config$.next(config);
return newConfig;
return config;
}
public async refreshConfig() {

View File

@ -2,6 +2,7 @@ import {
AudioCodec,
Colorspace,
CQMode,
LogLevel,
SystemConfig,
SystemConfigEntity,
SystemConfigKey,
@ -57,6 +58,10 @@ const updatedConfig = Object.freeze<SystemConfig>({
accel: TranscodeHWAccel.DISABLED,
tonemap: ToneMapping.HABLE,
},
logging: {
enabled: true,
level: LogLevel.LOG,
},
machineLearning: {
enabled: true,
url: 'http://immich-machine-learning:3003',
@ -159,7 +164,7 @@ describe(SystemConfigService.name, () => {
const validator: SystemConfigValidator = jest.fn();
sut.addValidator(validator);
await sut.updateConfig(defaults);
expect(validator).toHaveBeenCalledWith(defaults);
expect(validator).toHaveBeenCalledWith(defaults, defaults);
});
});
@ -279,7 +284,7 @@ describe(SystemConfigService.name, () => {
await expect(sut.updateConfig(updatedConfig)).rejects.toBeInstanceOf(BadRequestException);
expect(validator).toHaveBeenCalledWith(updatedConfig);
expect(validator).toHaveBeenCalledWith(updatedConfig, defaults);
expect(configMock.saveAll).not.toHaveBeenCalled();
});

View File

@ -1,4 +1,8 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { LogLevel, SystemConfig } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { instanceToPlain } from 'class-transformer';
import _ from 'lodash';
import {
ClientEvent,
ICommunicationRepository,
@ -22,7 +26,7 @@ import { SystemConfigCore, SystemConfigValidator } from './system-config.core';
@Injectable()
export class SystemConfigService {
private logger = new Logger(SystemConfigService.name);
private logger = new ImmichLogger(SystemConfigService.name);
private core: SystemConfigCore;
constructor(
@ -32,6 +36,13 @@ export class SystemConfigService {
) {
this.core = SystemConfigCore.create(repository);
this.communicationRepository.on(ServerEvent.CONFIG_UPDATE, () => this.handleConfigUpdate());
this.core.config$.subscribe((config) => this.setLogLevel(config));
this.core.addValidator((newConfig, oldConfig) => this.validateConfig(newConfig, oldConfig));
}
async init() {
const config = await this.core.getConfig();
await this.setLogLevel(config);
}
get config$() {
@ -106,4 +117,22 @@ export class SystemConfigService {
private async handleConfigUpdate() {
await this.core.refreshConfig();
}
private async setLogLevel({ logging }: SystemConfig) {
const envLevel = this.getEnvLogLevel();
const configLevel = logging.enabled ? logging.level : false;
const level = envLevel ? envLevel : configLevel;
ImmichLogger.setLogLevel(level);
this.logger.log(`LogLevel=${level} ${envLevel ? '(set via LOG_LEVEL)' : '(set via system config)'}`);
}
private getEnvLogLevel() {
return process.env.LOG_LEVEL as LogLevel;
}
private async validateConfig(newConfig: SystemConfig, oldConfig: SystemConfig) {
if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) {
throw new Error('Logging cannot be changed while the environment variable LOG_LEVEL is set.');
}
}
}

View File

@ -1,5 +1,6 @@
import { UserEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { randomBytes } from 'crypto';
import { AuthDto } from '../auth';
import { ImmichFileResponse } from '../domain.util';
@ -21,7 +22,7 @@ import { UserCore } from './user.core';
@Injectable()
export class UserService {
private logger = new Logger(UserService.name);
private logger = new ImmichLogger(UserService.name);
private userCore: UserCore;
constructor(

View File

@ -15,7 +15,8 @@ import {
UploadFile,
} from '@app/domain';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
import { Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { QueryFailedError } from 'typeorm';
import { IAssetRepository } from './asset-repository';
import { AssetCore } from './asset.core';
@ -38,7 +39,7 @@ import { CuratedObjectsResponseDto } from './response-dto/curated-objects-respon
@Injectable()
export class AssetService {
readonly logger = new Logger(AssetService.name);
readonly logger = new ImmichLogger(AssetService.name);
private assetCore: AssetCore;
private access: AccessCore;

View File

@ -1,9 +1,9 @@
import { AuthDto, AuthService, IMMICH_API_KEY_NAME, LoginDetails } from '@app/domain';
import { ImmichLogger } from '@app/infra/logger';
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
SetMetadata,
applyDecorators,
createParamDecorator,
@ -77,7 +77,7 @@ export interface AuthRequest extends Request {
@Injectable()
export class AppGuard implements CanActivate {
private logger = new Logger(AppGuard.name);
private logger = new ImmichLogger(AppGuard.name);
constructor(
private reflector: Reflector,

View File

@ -6,8 +6,10 @@ import {
ServerInfoService,
SharedLinkService,
StorageService,
SystemConfigService,
} from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression, Interval } from '@nestjs/schedule';
import { NextFunction, Request, Response } from 'express';
import { readFileSync } from 'fs';
@ -34,10 +36,11 @@ const render = (index: string, meta: OpenGraphTags) => {
@Injectable()
export class AppService {
private logger = new Logger(AppService.name);
private logger = new ImmichLogger(AppService.name);
constructor(
private authService: AuthService,
private configService: SystemConfigService,
private jobService: JobService,
private serverService: ServerInfoService,
private sharedLinkService: SharedLinkService,
@ -55,6 +58,7 @@ export class AppService {
}
async init() {
await this.configService.init();
this.storageService.init();
await this.serverService.handleVersionCheck();
this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`);

View File

@ -1,10 +1,10 @@
import { ImmichLogger } from '@app/infra/logger';
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
InternalServerErrorException,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { Observable, catchError, throwError } from 'rxjs';
@ -13,7 +13,7 @@ import { routeToErrorMessage } from '../app.utils';
@Injectable()
export class ErrorInterceptor implements NestInterceptor {
private logger = new Logger(ErrorInterceptor.name);
private logger = new ImmichLogger(ErrorInterceptor.name);
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
return next.handle().pipe(

View File

@ -1,5 +1,6 @@
import { ImmichFileResponse, isConnectionAborted } from '@app/domain';
import { CallHandler, ExecutionContext, Logger, NestInterceptor } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Response } from 'express';
import { access, constants } from 'fs/promises';
import { isAbsolute } from 'path';
@ -10,7 +11,7 @@ type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1];
export class FileServeInterceptor implements NestInterceptor {
private logger = new Logger(FileServeInterceptor.name);
private logger = new ImmichLogger(FileServeInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
const http = context.switchToHttp();

View File

@ -1,5 +1,6 @@
import { AssetService, UploadFieldName, UploadFile } from '@app/domain';
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
@ -52,7 +53,7 @@ const asRequest = (req: AuthRequest, file: Express.Multer.File) => {
@Injectable()
export class FileUploadInterceptor implements NestInterceptor {
private logger = new Logger(FileUploadInterceptor.name);
private logger = new ImmichLogger(FileUploadInterceptor.name);
private handlers: {
userProfile: RequestHandler;

View File

@ -1,6 +1,6 @@
import { envName, getLogLevels, isDev, serverVersion } from '@app/domain';
import { envName, isDev, serverVersion } from '@app/domain';
import { WebSocketAdapter, enablePrefilter } from '@app/infra';
import { Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
@ -9,12 +9,13 @@ import { AppModule } from './app.module';
import { AppService } from './app.service';
import { useSwagger } from './app.utils';
const logger = new Logger('ImmichServer');
const logger = new ImmichLogger('ImmichServer');
const port = Number(process.env.SERVER_PORT) || 3001;
export async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger: getLogLevels() });
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bufferLogs: true });
app.useLogger(app.get(ImmichLogger));
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
app.set('etag', 'strong');
app.use(cookieParser());

View File

@ -45,6 +45,12 @@ export enum SystemConfigKey {
JOB_LIBRARY_CONCURRENCY = 'job.library.concurrency',
JOB_MIGRATION_CONCURRENCY = 'job.migration.concurrency',
LIBRARY_SCAN_ENABLED = 'library.scan.enabled',
LIBRARY_SCAN_CRON_EXPRESSION = 'library.scan.cronExpression',
LOGGING_ENABLED = 'logging.enabled',
LOGGING_LEVEL = 'logging.level',
MACHINE_LEARNING_ENABLED = 'machineLearning.enabled',
MACHINE_LEARNING_URL = 'machineLearning.url',
@ -94,9 +100,6 @@ export enum SystemConfigKey {
TRASH_DAYS = 'trash.days',
THEME_CUSTOM_CSS = 'theme.customCss',
LIBRARY_SCAN_ENABLED = 'library.scan.enabled',
LIBRARY_SCAN_CRON_EXPRESSION = 'library.scan.cronExpression',
}
export enum TranscodePolicy {
@ -144,6 +147,15 @@ export enum Colorspace {
P3 = 'p3',
}
export enum LogLevel {
VERBOSE = 'verbose',
DEBUG = 'debug',
LOG = 'log',
WARN = 'warn',
ERROR = 'error',
FATAL = 'fatal',
}
export interface SystemConfig {
ffmpeg: {
crf: number;
@ -165,6 +177,10 @@ export interface SystemConfig {
tonemap: ToneMapping;
};
job: Record<QueueName, { concurrency: number }>;
logging: {
enabled: boolean;
level: LogLevel;
};
machineLearning: {
enabled: boolean;
url: string;

View File

@ -0,0 +1,21 @@
import { ConsoleLogger } from '@nestjs/common';
import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util';
import { LogLevel } from './entities';
const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL];
export class ImmichLogger extends ConsoleLogger {
private static logLevels: LogLevel[] = [];
constructor(context: string) {
super(context);
}
isLevelEnabled(level: LogLevel) {
return isLogLevelEnabled(level, ImmichLogger.logLevels);
}
static setLogLevel(level: LogLevel | false): void {
ImmichLogger.logLevels = level === false ? [] : LOG_LEVELS.slice(LOG_LEVELS.indexOf(level));
}
}

View File

@ -6,7 +6,7 @@ import {
OnServerEventCallback,
ServerEvent,
} from '@app/domain';
import { Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import {
OnGatewayConnection,
OnGatewayDisconnect,
@ -20,7 +20,7 @@ import { Server, Socket } from 'socket.io';
export class CommunicationRepository
implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, ICommunicationRepository
{
private logger = new Logger(CommunicationRepository.name);
private logger = new ImmichLogger(CommunicationRepository.name);
private onConnectCallbacks: OnConnectCallback[] = [];
private onServerEventCallbacks: Record<ServerEvent, OnServerEventCallback[]> = {
[ServerEvent.CONFIG_UPDATE]: [],

View File

@ -6,7 +6,7 @@ import {
IStorageRepository,
mimeTypes,
} from '@app/domain';
import { Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import archiver from 'archiver';
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
import fs, { readdir, writeFile } from 'fs/promises';
@ -18,7 +18,7 @@ import path from 'path';
const moveFile = promisify<string, string, mv.Options>(mv);
export class FilesystemProvider implements IStorageRepository {
private logger = new Logger(FilesystemProvider.name);
private logger = new ImmichLogger(FilesystemProvider.name);
createZipStream(): ImmichZipStream {
const archive = archiver('zip', { store: true });

View File

@ -8,8 +8,9 @@ import {
QueueName,
QueueStatus,
} from '@app/domain';
import { ImmichLogger } from '@app/infra/logger';
import { getQueueToken } from '@nestjs/bullmq';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { SchedulerRegistry } from '@nestjs/schedule';
import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq';
@ -19,7 +20,7 @@ import { bullConfig } from '../infra.config';
@Injectable()
export class JobRepository implements IJobRepository {
private workers: Partial<Record<QueueName, Worker>> = {};
private logger = new Logger(JobRepository.name);
private logger = new ImmichLogger(JobRepository.name);
constructor(
private moduleRef: ModuleRef,

View File

@ -1,6 +1,6 @@
import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain';
import { Colorspace } from '@app/infra/entities';
import { Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import fs from 'fs/promises';
import sharp from 'sharp';
@ -11,7 +11,7 @@ const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
sharp.concurrency(0);
export class MediaRepository implements IMediaRepository {
private logger = new Logger(MediaRepository.name);
private logger = new ImmichLogger(MediaRepository.name);
crop(input: string | Buffer, options: CropOptions): Promise<Buffer> {
return sharp(input, { failOn: 'none' })

View File

@ -7,7 +7,8 @@ import {
} from '@app/domain';
import { DatabaseLock, RequireLock } from '@app/infra';
import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
import { Inject, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DefaultReadTaskOptions, exiftool, Tags } from 'exiftool-vendored';
import { createReadStream, existsSync } from 'fs';
@ -31,7 +32,7 @@ export class MetadataRepository implements IMetadataRepository {
@InjectDataSource() private dataSource: DataSource,
) {}
private logger = new Logger(MetadataRepository.name);
private logger = new ImmichLogger(MetadataRepository.name);
@RequireLock(DatabaseLock.GeodataImport)
async init(): Promise<void> {

View File

@ -2,7 +2,8 @@ import { Embedding, EmbeddingSearch, ISmartInfoRepository } from '@app/domain';
import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
import { DatabaseLock, RequireLock, asyncLock } from '@app/infra';
import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities';
import { Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DummyValue, GenerateSql } from '../infra.util';
@ -10,7 +11,7 @@ import { asVector, isValidInteger } from '../infra.utils';
@Injectable()
export class SmartInfoRepository implements ISmartInfoRepository {
private logger = new Logger(SmartInfoRepository.name);
private logger = new ImmichLogger(SmartInfoRepository.name);
private faceColumns: string[];
constructor(

View File

@ -8,37 +8,33 @@ import {
MediaService,
MetadataService,
PersonService,
ServerInfoService,
SmartInfoService,
StorageService,
StorageTemplateService,
SystemConfigService,
UserService,
} from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
private logger = new Logger(AppService.name);
constructor(
private auditService: AuditService,
private assetService: AssetService,
private configService: SystemConfigService,
private jobService: JobService,
private libraryService: LibraryService,
private mediaService: MediaService,
private metadataService: MetadataService,
private personService: PersonService,
private serverInfoService: ServerInfoService,
private smartInfoService: SmartInfoService,
private storageTemplateService: StorageTemplateService,
private storageService: StorageService,
private systemConfigService: SystemConfigService,
private userService: UserService,
) {}
async init() {
await this.configService.init();
await this.jobService.registerHandlers({
[JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data),
[JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(),

View File

@ -1,20 +1,19 @@
import { envName, getLogLevels, serverVersion } from '@app/domain';
import { envName, serverVersion } from '@app/domain';
import { WebSocketAdapter, enablePrefilter } from '@app/infra';
import { Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { NestFactory } from '@nestjs/core';
import { AppService } from './app.service';
import { MicroservicesModule } from './microservices.module';
const logger = new Logger('ImmichMicroservice');
const logger = new ImmichLogger('ImmichMicroservice');
const port = Number(process.env.MICROSERVICES_PORT) || 3002;
export async function bootstrap() {
const app = await NestFactory.create(MicroservicesModule, { logger: getLogLevels() });
const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true });
app.useLogger(app.get(ImmichLogger));
app.useWebSocketAdapter(new WebSocketAdapter(app));
await enablePrefilter();
await app.get(AppService).init();
await app.listen(port);
logger.log(`Immich Microservices is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `);

View File

@ -1,10 +1,16 @@
import { DomainModule } from '@app/domain';
import { InfraModule } from '@app/infra';
import { Module } from '@nestjs/common';
import { Module, OnModuleInit } from '@nestjs/common';
import { AppService } from './app.service';
@Module({
imports: [DomainModule.register({ imports: [InfraModule] })],
providers: [AppService],
})
export class MicroservicesModule {}
export class MicroservicesModule implements OnModuleInit {
constructor(private appService: AppService) {}
async onModuleInit() {
await this.appService.init();
}
}

View File

@ -1,11 +0,0 @@
jest.mock('@nestjs/common', () => ({
...jest.requireActual('@nestjs/common'),
Logger: jest.fn().mockReturnValue({
verbose: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));

View File

@ -2175,6 +2175,24 @@ export const LibraryType = {
export type LibraryType = typeof LibraryType[keyof typeof LibraryType];
/**
*
* @export
* @enum {string}
*/
export const LogLevel = {
Verbose: 'verbose',
Debug: 'debug',
Log: 'log',
Warn: 'warn',
Error: 'error',
Fatal: 'fatal'
} as const;
export type LogLevel = typeof LogLevel[keyof typeof LogLevel];
/**
*
* @export
@ -3577,6 +3595,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto
*/
'library': SystemConfigLibraryDto;
/**
*
* @type {SystemConfigLoggingDto}
* @memberof SystemConfigDto
*/
'logging': SystemConfigLoggingDto;
/**
*
* @type {SystemConfigMachineLearningDto}
@ -3860,6 +3884,27 @@ export interface SystemConfigLibraryScanDto {
*/
'enabled': boolean;
}
/**
*
* @export
* @interface SystemConfigLoggingDto
*/
export interface SystemConfigLoggingDto {
/**
*
* @type {boolean}
* @memberof SystemConfigLoggingDto
*/
'enabled': boolean;
/**
*
* @type {LogLevel}
* @memberof SystemConfigLoggingDto
*/
'level': LogLevel;
}
/**
*
* @export

View File

@ -0,0 +1,110 @@
<script lang="ts">
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api, LogLevel, SystemConfigLoggingDto } from '@api';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingSelect from '../setting-select.svelte';
export let loggingConfig: SystemConfigLoggingDto; // this is the config that is being edited
export let disabled = false;
let savedConfig: SystemConfigLoggingDto;
let defaultConfig: SystemConfigLoggingDto;
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.logging),
api.systemConfigApi.getConfigDefaults().then((res) => res.data.logging),
]);
}
async function saveSetting() {
try {
const { data: current } = await api.systemConfigApi.getConfig();
const { data: updated } = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...current,
logging: loggingConfig,
},
});
loggingConfig = { ...updated.logging };
savedConfig = { ...updated.logging };
notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save settings');
}
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
loggingConfig = { ...resetConfig.logging };
savedConfig = { ...resetConfig.logging };
notificationController.show({
message: 'Reset settings to the recent saved settings',
type: NotificationType.Info,
});
}
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getConfigDefaults();
loggingConfig = { ...configs.logging };
defaultConfig = { ...configs.logging };
notificationController.show({
message: 'Reset password settings to default',
type: NotificationType.Info,
});
}
</script>
<div>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4">
<SettingSwitch title="ENABLED" {disabled} subtitle="Logging" bind:checked={loggingConfig.enabled} />
</div>
<div class="ml-4">
<SettingSelect
label="LEVEL"
desc="When enabled, what log level to use."
bind:value={loggingConfig.level}
options={[
{ value: LogLevel.Fatal, text: 'Fatal' },
{ value: LogLevel.Error, text: 'Error' },
{ value: LogLevel.Warn, text: 'Warn' },
{ value: LogLevel.Log, text: 'Log' },
{ value: LogLevel.Debug, text: 'Debug' },
{ value: LogLevel.Verbose, text: 'Verbose' },
]}
name="level"
isEdited={loggingConfig.level !== savedConfig.level}
disabled={disabled || !loggingConfig.enabled}
/>
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>
</div>
</form>
</div>
{/await}
</div>

View File

@ -21,6 +21,7 @@
import type { PageData } from './$types';
import NewVersionCheckSettings from '$lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte';
import LibrarySettings from '$lib/components/admin-page/settings/library-settings/library-settings.svelte';
import LoggingSettings from '$lib/components/admin-page/settings/logging-settings/logging-settings.svelte';
import { mdiAlert, mdiContentCopy, mdiDownload } from '@mdi/js';
export let data: PageData;
@ -74,6 +75,10 @@
<LibrarySettings disabled={$featureFlags.configFile} libraryConfig={configs.library} />
</SettingAccordion>
<SettingAccordion title="Logging" subtitle="Manage log settings">
<LoggingSettings disabled={$featureFlags.configFile} loggingConfig={configs.logging} />
</SettingAccordion>
<SettingAccordion title="Machine Learning Settings" subtitle="Manage machine learning features and settings">
<MachineLearningSettings disabled={$featureFlags.configFile} machineLearningConfig={configs.machineLearning} />
</SettingAccordion>