1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

refactor(server): new version check (#9555)

This commit is contained in:
Jason Rasmussen 2024-05-17 12:22:39 -04:00 committed by GitHub
parent 4807fc40a6
commit c03981ac1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 257 additions and 102 deletions

View file

@ -17,7 +17,7 @@ export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const envName = (process.env.NODE_ENV || 'development').toUpperCase();
export const isDev = process.env.NODE_ENV === 'development';
export const isDev = () => process.env.NODE_ENV === 'development';
export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload';
export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www';

View file

@ -12,11 +12,15 @@ import {
} from 'src/dtos/server-info.dto';
import { Authenticated } from 'src/middleware/auth.guard';
import { ServerInfoService } from 'src/services/server-info.service';
import { VersionService } from 'src/services/version.service';
@ApiTags('Server Info')
@Controller('server-info')
export class ServerInfoController {
constructor(private service: ServerInfoService) {}
constructor(
private service: ServerInfoService,
private versionService: VersionService,
) {}
@Get()
@Authenticated()
@ -31,7 +35,7 @@ export class ServerInfoController {
@Get('version')
getServerVersion(): ServerVersionResponseDto {
return this.service.getVersion();
return this.versionService.getVersion();
}
@Get('features')

View file

@ -1,7 +1,6 @@
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
import type { DateTime } from 'luxon';
import { SystemConfigThemeDto } from 'src/dtos/system-config.dto';
import { IVersion, VersionType } from 'src/utils/version';
import { IVersion } from 'src/utils/version';
export class ServerPingResponse {
@ApiResponseProperty({ type: String, example: 'pong' })
@ -112,8 +111,9 @@ export class ServerFeaturesDto {
}
export interface ReleaseNotification {
isAvailable: VersionType;
checkedAt: DateTime<boolean> | null;
isAvailable: boolean;
/** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}

View file

@ -14,10 +14,14 @@ export enum SystemMetadataKey {
REVERSE_GEOCODING_STATE = 'reverse-geocoding-state',
ADMIN_ONBOARDING = 'admin-onboarding',
SYSTEM_CONFIG = 'system-config',
VERSION_CHECK_STATE = 'version-check-state',
}
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
}

View file

@ -100,6 +100,9 @@ export enum JobName {
// Notification
NOTIFY_SIGNUP = 'notify-signup',
SEND_EMAIL = 'notification-send-email',
// Version check
VERSION_CHECK = 'version-check',
}
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
@ -243,7 +246,10 @@ export type JobItem =
// Notification
| { name: JobName.SEND_EMAIL; data: IEmailJob }
| { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob };
| { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob }
// Version check
| { name: JobName.VERSION_CHECK; data: IBaseJob };
export enum JobStatus {
SUCCESS = 'success',

View file

@ -86,6 +86,9 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
// Notification
[JobName.SEND_EMAIL]: QueueName.NOTIFICATION,
[JobName.NOTIFY_SIGNUP]: QueueName.NOTIFICATION,
// Version check
[JobName.VERSION_CHECK]: QueueName.BACKGROUND_TASK,
};
@Instrumentation()

View file

@ -12,6 +12,7 @@ import { ServerInfoService } from 'src/services/server-info.service';
import { SharedLinkService } from 'src/services/shared-link.service';
import { StorageService } from 'src/services/storage.service';
import { SystemConfigService } from 'src/services/system-config.service';
import { VersionService } from 'src/services/version.service';
import { OpenGraphTags } from 'src/utils/misc';
const render = (index: string, meta: OpenGraphTags) => {
@ -44,6 +45,7 @@ export class ApiService {
private sharedLinkService: SharedLinkService,
private storageService: StorageService,
private databaseService: DatabaseService,
private versionService: VersionService,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(ApiService.name);
@ -51,7 +53,7 @@ export class ApiService {
@Interval(ONE_HOUR.as('milliseconds'))
async onVersionCheck() {
await this.serverService.handleVersionCheck();
await this.versionService.handleQueueVersionCheck();
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
@ -64,6 +66,7 @@ export class ApiService {
await this.configService.init();
this.storageService.init();
await this.serverService.init();
await this.versionService.init();
this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`);
}

View file

@ -32,6 +32,7 @@ import { TagService } from 'src/services/tag.service';
import { TimelineService } from 'src/services/timeline.service';
import { TrashService } from 'src/services/trash.service';
import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
export const services = [
ApiService,
@ -68,4 +69,5 @@ export const services = [
TimelineService,
TrashService,
UserService,
VersionService,
];

View file

@ -16,6 +16,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service';
import { SystemConfigService } from 'src/services/system-config.service';
import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
import { otelSDK } from 'src/utils/instrumentation';
@Injectable()
@ -37,6 +38,7 @@ export class MicroservicesService {
private storageService: StorageService,
private userService: UserService,
private duplicateService: DuplicateService,
private versionService: VersionService,
) {}
async init() {
@ -89,6 +91,7 @@ export class MicroservicesService {
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
[JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data),
[JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data),
[JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(),
});
await this.metadataService.init();

View file

@ -1,37 +1,28 @@
import { serverVersion } from 'src/constants';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { ServerInfoService } from 'src/services/server-info.service';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
describe(ServerInfoService.name, () => {
let sut: ServerInfoService;
let eventMock: Mocked<IEventRepository>;
let serverInfoMock: Mocked<IServerInfoRepository>;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
eventMock = newEventRepositoryMock();
serverInfoMock = newServerInfoRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new ServerInfoService(eventMock, userMock, serverInfoMock, storageMock, systemMock, loggerMock);
sut = new ServerInfoService(userMock, storageMock, systemMock, loggerMock);
});
it('should work', () => {
@ -154,12 +145,6 @@ describe(ServerInfoService.name, () => {
});
});
describe('getVersion', () => {
it('should respond the server version', () => {
expect(sut.getVersion()).toEqual(serverVersion);
});
});
describe('getFeatures', () => {
it('should respond the server features', async () => {
await expect(sut.getFeatures()).resolves.toEqual({

View file

@ -1,9 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { isDev, serverVersion } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnServerEvent } from 'src/decorators';
import {
ServerConfigDto,
ServerFeaturesDto,
@ -14,27 +11,20 @@ import {
UsageByUserDto,
} from 'src/dtos/server-info.dto';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface';
import { asHumanReadable } from 'src/utils/bytes';
import { mimeTypes } from 'src/utils/mime-types';
import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc';
import { Version } from 'src/utils/version';
@Injectable()
export class ServerInfoService {
private configCore: SystemConfigCore;
private releaseVersion = serverVersion;
private releaseVersionCheckedAt: DateTime | null = null;
constructor(
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IServerInfoRepository) private repository: IServerInfoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@ -43,11 +33,7 @@ export class ServerInfoService {
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
}
onConnect() {}
async init(): Promise<void> {
await this.handleVersionCheck();
const featureFlags = await this.getFeatures();
if (featureFlags.configFile) {
await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, {
@ -77,10 +63,6 @@ export class ServerInfoService {
return { res: 'pong' };
}
getVersion() {
return serverVersion;
}
async getFeatures(): Promise<ServerFeaturesDto> {
const { reverseGeocoding, map, machineLearning, trash, oauth, passwordLogin, notifications } =
await this.configCore.getConfig();
@ -152,57 +134,4 @@ export class ServerInfoService {
sidecar: Object.keys(mimeTypes.sidecar),
};
}
async handleVersionCheck(): Promise<boolean> {
try {
if (isDev) {
return true;
}
const { newVersionCheck } = await this.configCore.getConfig();
if (!newVersionCheck.enabled) {
return true;
}
// check once per hour (max)
if (this.releaseVersionCheckedAt && DateTime.now().diff(this.releaseVersionCheckedAt).as('minutes') < 60) {
return true;
}
const githubRelease = await this.repository.getGitHubRelease();
const githubVersion = Version.fromString(githubRelease.tag_name);
const publishedAt = new Date(githubRelease.published_at);
this.releaseVersion = githubVersion;
this.releaseVersionCheckedAt = DateTime.now();
if (githubVersion.isNewerThan(serverVersion)) {
this.logger.log(`Found ${githubVersion.toString()}, released at ${publishedAt.toLocaleString()}`);
this.newReleaseNotification();
}
} catch (error: Error | any) {
this.logger.warn(`Unable to run version check: ${error}`, error?.stack);
}
return true;
}
@OnServerEvent(ServerEvent.WEBSOCKET_CONNECT)
onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) {
this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion);
this.newReleaseNotification(userId);
}
private newReleaseNotification(userId?: string) {
const event = ClientEvent.NEW_RELEASE;
const payload = {
isAvailable: this.releaseVersion.isNewerThan(serverVersion),
checkedAt: this.releaseVersionCheckedAt,
serverVersion,
releaseVersion: this.releaseVersion,
};
userId
? this.eventRepository.clientSend(event, userId, payload)
: this.eventRepository.clientBroadcast(event, payload);
}
}

View file

@ -0,0 +1,110 @@
import { DateTime } from 'luxon';
import { serverVersion } from 'src/constants';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { VersionService } from 'src/services/version.service';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest';
const mockRelease = (version = '100.0.0') => ({
id: 1,
url: 'https://api.github.com/repos/owner/repo/releases/1',
tag_name: 'v' + version,
name: 'Release 1000',
created_at: DateTime.utc().toISO(),
published_at: DateTime.utc().toISO(),
body: '',
});
describe(VersionService.name, () => {
let sut: VersionService;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let serverMock: Mocked<IServerInfoRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
serverMock = newServerInfoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new VersionService(eventMock, jobMock, serverMock, systemMock, loggerMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getVersion', () => {
it('should respond the server version', () => {
expect(sut.getVersion()).toEqual(serverVersion);
});
});
describe('handQueueVersionCheck', () => {
it('should queue a version check job', async () => {
await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined();
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VERSION_CHECK, data: {} });
});
});
describe('handVersionCheck', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production';
});
it('should not run in dev mode', async () => {
process.env.NODE_ENV = 'development';
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED);
});
it('should not run if the last check was < 60 minutes ago', async () => {
systemMock.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(),
releaseVersion: '1.0.0',
});
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED);
});
it('should run if it has been > 60 minutes', async () => {
serverMock.getGitHubRelease.mockResolvedValue(mockRelease());
systemMock.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(),
releaseVersion: '1.0.0',
});
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(systemMock.set).toHaveBeenCalled();
expect(loggerMock.log).toHaveBeenCalled();
expect(eventMock.clientBroadcast).toHaveBeenCalled();
});
it('should not notify if the version is equal', async () => {
serverMock.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString()));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, {
checkedAt: expect.any(String),
releaseVersion: serverVersion.toString(),
});
expect(eventMock.clientBroadcast).not.toHaveBeenCalled();
});
it('should handle a github error', async () => {
serverMock.getGitHubRelease.mockRejectedValue(new Error('GitHub is down'));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.FAILED);
expect(systemMock.set).not.toHaveBeenCalled();
expect(eventMock.clientBroadcast).not.toHaveBeenCalled();
expect(loggerMock.warn).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,105 @@
import { Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { isDev, serverVersion } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnServerEvent } from 'src/decorators';
import { ReleaseNotification } from 'src/dtos/server-info.dto';
import { SystemMetadataKey, VersionCheckMetadata } from 'src/entities/system-metadata.entity';
import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { Version } from 'src/utils/version';
const asNotification = ({ releaseVersion, checkedAt }: VersionCheckMetadata): ReleaseNotification => {
const version = Version.fromString(releaseVersion);
return {
isAvailable: version.isNewerThan(serverVersion) !== 0,
checkedAt,
serverVersion,
releaseVersion: version,
};
};
@Injectable()
export class VersionService {
private configCore: SystemConfigCore;
constructor(
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IServerInfoRepository) private repository: IServerInfoRepository,
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(VersionService.name);
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
}
async init(): Promise<void> {
await this.handleVersionCheck();
}
getVersion() {
return serverVersion;
}
async handleQueueVersionCheck() {
await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} });
}
async handleVersionCheck(): Promise<JobStatus> {
try {
this.logger.debug('Running version check');
if (isDev()) {
return JobStatus.SKIPPED;
}
const { newVersionCheck } = await this.configCore.getConfig();
if (!newVersionCheck.enabled) {
return JobStatus.SKIPPED;
}
const versionCheck = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE);
if (versionCheck?.checkedAt) {
const lastUpdate = DateTime.fromISO(versionCheck.checkedAt);
const elapsedTime = DateTime.now().diff(lastUpdate).as('minutes');
// check once per hour (max)
if (elapsedTime < 60) {
return JobStatus.SKIPPED;
}
}
const githubRelease = await this.repository.getGitHubRelease();
const githubVersion = Version.fromString(githubRelease.tag_name);
const metadata: VersionCheckMetadata = {
checkedAt: DateTime.utc().toISO(),
releaseVersion: githubVersion.toString(),
};
await this.systemMetadataRepository.set(SystemMetadataKey.VERSION_CHECK_STATE, metadata);
if (githubVersion.isNewerThan(serverVersion)) {
const publishedAt = new Date(githubRelease.published_at);
this.logger.log(`Found ${githubVersion.toString()}, released at ${publishedAt.toLocaleString()}`);
this.eventRepository.clientBroadcast(ClientEvent.NEW_RELEASE, asNotification(metadata));
}
} catch (error: Error | any) {
this.logger.warn(`Unable to run version check: ${error}`, error?.stack);
return JobStatus.FAILED;
}
return JobStatus.SUCCESS;
}
@OnServerEvent(ServerEvent.WEBSOCKET_CONNECT)
async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) {
this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion);
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE);
if (metadata) {
this.eventRepository.clientSend(ClientEvent.NEW_RELEASE, userId, asNotification(metadata));
}
}
}

View file

@ -11,7 +11,7 @@ import _ from 'lodash';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { SystemConfig } from 'src/config';
import { CLIP_MODEL_INFO, serverVersion } from 'src/constants';
import { CLIP_MODEL_INFO, isDev, serverVersion } from 'src/constants';
import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { Metadata } from 'src/middleware/auth.guard';
@ -174,7 +174,7 @@ const patchOpenAPI = (document: OpenAPIObject) => {
return document;
};
export const useSwagger = (app: INestApplication, isDevelopment: boolean) => {
export const useSwagger = (app: INestApplication) => {
const config = new DocumentBuilder()
.setTitle('Immich')
.setDescription('Immich API')
@ -211,7 +211,7 @@ export const useSwagger = (app: INestApplication, isDevelopment: boolean) => {
SwaggerModule.setup('doc', app, specification, customOptions);
if (isDevelopment) {
if (isDev()) {
// Generate API Documentation only in development mode
const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json');
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' });

View file

@ -29,11 +29,11 @@ async function bootstrap() {
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (isDev) {
if (isDev()) {
app.enableCors();
}
app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, isDev);
useSwagger(app);
app.setGlobalPrefix('api', { exclude: excludePaths });
if (existsSync(WEB_ROOT)) {

View file

@ -6,7 +6,8 @@ import { user } from './user.store';
export interface ReleaseEvent {
isAvailable: boolean;
checkedAt: Date;
/** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}