feat: synchronised status, restore db action

This commit is contained in:
izzy
2025-11-20 15:24:48 +00:00
parent 442fe6e3d0
commit 26587dd690
8 changed files with 129 additions and 29 deletions

View File

@@ -16604,6 +16604,9 @@
"type": "string"
}
},
"required": [
"action"
],
"type": "object"
},
"ManualJobName": {

View File

@@ -146,6 +146,10 @@ export class MaintenanceModule {
this.maintenanceWorkerService.authenticate(client.request.headers),
);
this.maintenanceWebsocketRepository.setStatusUpdateFn((status) =>
this.maintenanceEphemeralStateRepository.setStatus(status),
);
await this.maintenanceWorkerService.logSecret();
}
}

View File

@@ -27,7 +27,9 @@ export class MaintenanceController {
history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'),
})
maintenanceStatus(): MaintenanceStatusResponseDto {
return {};
return {
action: MaintenanceAction.End,
};
}
@Post('login')

View File

@@ -19,7 +19,7 @@ export class MaintenanceAuthDto {
}
export class MaintenanceStatusResponseDto {
action?: MaintenanceAction;
action!: MaintenanceAction;
progress?: number;
task?: string;

View File

@@ -1,10 +1,13 @@
import { Injectable } from '@nestjs/common';
import { MaintenanceStatusResponseDto } from 'src/dtos/maintenance.dto';
import { MaintenanceAction } from 'src/enum';
@Injectable()
export class MaintenanceEphemeralStateRepository {
#secret: string = null!;
#state: MaintenanceStatusResponseDto = {};
#state: MaintenanceStatusResponseDto = {
action: MaintenanceAction.Start,
};
setSecret(secret: string) {
this.#secret = secret;
@@ -14,15 +17,15 @@ export class MaintenanceEphemeralStateRepository {
return this.#secret;
}
setState(state: MaintenanceStatusResponseDto) {
setStatus(state: MaintenanceStatusResponseDto) {
this.#state = state;
}
getState(): MaintenanceStatusResponseDto {
getStatus(): MaintenanceStatusResponseDto {
return this.#state;
}
getPublicState(): MaintenanceStatusResponseDto {
getPublicStatus(): MaintenanceStatusResponseDto {
const state = structuredClone(this.#state);
if (state.error) {

View File

@@ -7,19 +7,23 @@ import {
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { MaintenanceAuthDto, MaintenanceStatusResponseDto } from 'src/dtos/maintenance.dto';
import { AppRepository } from 'src/repositories/app.repository';
import { AppRestartEvent, ArgsOf } from 'src/repositories/event.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
export const serverEvents = ['AppRestart'] as const;
export type ServerEvents = (typeof serverEvents)[number];
interface ServerEventMap {
AppRestart: [AppRestartEvent];
MaintenanceStatus: [MaintenanceStatusResponseDto];
}
export interface ClientEventMap {
interface ClientEventMap {
AppRestartV1: [AppRestartEvent];
MaintenanceStatusV1: [MaintenanceStatusResponseDto];
}
type AuthFn = (client: Socket) => Promise<MaintenanceAuthDto>;
type StatusUpdateFn = (status: MaintenanceStatusResponseDto) => void;
@WebSocketGateway({
cors: true,
@@ -29,9 +33,10 @@ type AuthFn = (client: Socket) => Promise<MaintenanceAuthDto>;
@Injectable()
export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
private authFn?: AuthFn;
private statusUpdateFn?: StatusUpdateFn;
@WebSocketServer()
private websocketServer?: Server;
private server?: Server;
constructor(
private logger: LoggingRepository,
@@ -40,18 +45,23 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
this.logger.setContext(MaintenanceWebsocketRepository.name);
}
afterInit(websocketServer: Server) {
afterInit(server: Server) {
this.logger.log('Initialized websocket server');
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
server.on('AppRestart', () => this.appRepository.exitApp());
server.on('MaintenanceStatus', (status) => this.statusUpdateFn?.(status));
}
clientSend<T extends keyof ClientEventMap>(event: T, room: string, ...data: ClientEventMap[T]) {
this.server?.to(room).emit(event, ...data);
}
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
this.websocketServer?.emit(event, ...data);
this.server?.emit(event, ...data);
}
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
serverSend<T extends keyof ServerEventMap>(event: T, ...args: ServerEventMap[T]): void {
this.logger.debug(`Server event: ${event} (send)`);
this.websocketServer?.serverSideEmit(event, ...args);
this.server?.serverSideEmit(event, ...args);
}
async handleConnection(client: Socket) {
@@ -73,4 +83,8 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
setAuthFn(fn: (client: Socket) => Promise<MaintenanceAuthDto>) {
this.authFn = fn;
}
setStatusUpdateFn(fn: (status: MaintenanceStatusResponseDto) => void) {
this.statusUpdateFn = fn;
}
}

View File

@@ -7,7 +7,7 @@ import {
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ServerConfigDto } from 'src/dtos/server.dto';
import { ImmichCookie, MaintenanceAction } from 'src/enum';
import { ImmichCookie } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { GetLoginDetails } from 'src/middleware/auth.guard';
@@ -46,8 +46,6 @@ export class MaintenanceWorkerController {
@Post('admin/maintenance')
@MaintenanceRoute()
async setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): Promise<void> {
if (dto.action === MaintenanceAction.End) {
await this.service.endMaintenance();
}
await this.service.runAction(dto);
}
}

View File

@@ -4,9 +4,9 @@ import { NextFunction, Request, Response } from 'express';
import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http';
import { MaintenanceAuthDto, MaintenanceStatusResponseDto } from 'src/dtos/maintenance.dto';
import { MaintenanceAuthDto, MaintenanceStatusResponseDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ServerConfigDto } from 'src/dtos/server.dto';
import { ImmichCookie, SystemMetadataKey } from 'src/enum';
import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { AppRepository } from 'src/repositories/app.repository';
@@ -20,7 +20,7 @@ import { type ApiService as _ApiService } from 'src/services/api.service';
import { type BaseService as _BaseService } from 'src/services/base.service';
import { type ServerService as _ServerService } from 'src/services/server.service';
import { MaintenanceModeState } from 'src/types';
import { deleteBackup, listBackups } from 'src/utils/backups';
import { deleteBackup, listBackups, restoreBackup } from 'src/utils/backups';
import { getConfig } from 'src/utils/config';
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
@@ -35,7 +35,7 @@ export class MaintenanceWorkerService {
private appRepository: AppRepository,
private configRepository: ConfigRepository,
private systemMetadataRepository: SystemMetadataRepository,
private maintenanceWorkerRepository: MaintenanceWebsocketRepository,
private maintenanceWebsocketRepository: MaintenanceWebsocketRepository,
private maintenanceEphemeralStateRepository: MaintenanceEphemeralStateRepository,
private storageRepository: StorageRepository,
private processRepository: ProcessRepository,
@@ -130,6 +130,17 @@ export class MaintenanceWorkerService {
return '/usr/src/app/upload';
}
setStatus(status: MaintenanceStatusResponseDto): void {
this.maintenanceEphemeralStateRepository.setStatus(status);
this.maintenanceWebsocketRepository.serverSend('MaintenanceStatus', status);
this.maintenanceWebsocketRepository.clientSend('MaintenanceStatusV1', 'private', status);
this.maintenanceWebsocketRepository.clientSend(
'MaintenanceStatusV1',
'public',
this.maintenanceEphemeralStateRepository.getPublicStatus(),
);
}
async logSecret(): Promise<void> {
const { server } = await this.getConfig({ withCache: true });
@@ -153,9 +164,9 @@ export class MaintenanceWorkerService {
async status(potentiallyJwt?: string): Promise<MaintenanceStatusResponseDto> {
try {
await this.login(potentiallyJwt);
return this.maintenanceEphemeralStateRepository.getState();
return this.maintenanceEphemeralStateRepository.getStatus();
} catch {
return this.maintenanceEphemeralStateRepository.getPublicState();
return this.maintenanceEphemeralStateRepository.getPublicStatus();
}
}
@@ -174,13 +185,53 @@ export class MaintenanceWorkerService {
}
}
async runAction(action: SetMaintenanceModeDto) {
switch (action.action) {
case MaintenanceAction.Start:
return;
case MaintenanceAction.End:
return this.endMaintenance();
case MaintenanceAction.RestoreDatabase:
if (!action.restoreBackupFilename) return;
}
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
if (!lock) {
return;
}
this.logger.log(`Running maintenance action ${action.action}`);
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret: this.maintenanceEphemeralStateRepository.getSecret(),
action: {
action: MaintenanceAction.Start,
},
});
try {
switch (action.action) {
case MaintenanceAction.RestoreDatabase:
await this.restoreBackup(action.restoreBackupFilename);
break;
}
} catch (error) {
this.logger.error(`Encountered error running action: ${error}`);
this.setStatus({
action: action.action,
error: '' + error,
});
}
}
async endMaintenance(): Promise<void> {
const state: MaintenanceModeState = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
// => corresponds to notification.service.ts#onAppRestart
this.maintenanceWorkerRepository.clientBroadcast('AppRestartV1', state);
this.maintenanceWorkerRepository.serverSend('AppRestart', state);
this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state);
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
this.appRepository.exitApp();
}
@@ -188,6 +239,31 @@ export class MaintenanceWorkerService {
* Backups
*/
private async restoreBackup(filename: string): Promise<void> {
this.setStatus({
action: MaintenanceAction.RestoreDatabase,
progress: 0,
});
await restoreBackup(this.backupRepos, filename, (task, progress) =>
this.setStatus({
action: MaintenanceAction.RestoreDatabase,
progress,
task,
}),
);
this.setStatus({
action: MaintenanceAction.End,
});
// => corresponds to notification.service.ts#onAppRestart
const state: MaintenanceModeState = { isMaintenanceMode: false };
this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state);
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
this.appRepository.exitApp();
}
async listBackups(): Promise<Record<'backups' | 'failedBackups', string[]>> {
return listBackups(this.backupRepos);
}