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" "type": "string"
} }
}, },
"required": [
"action"
],
"type": "object" "type": "object"
}, },
"ManualJobName": { "ManualJobName": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,9 @@ import { NextFunction, Request, Response } from 'express';
import { jwtVerify } from 'jose'; import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http'; 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 { 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 { MaintenanceEphemeralStateRepository } from 'src/maintenance/maintenance-ephemeral-state.repository';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { AppRepository } from 'src/repositories/app.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 BaseService as _BaseService } from 'src/services/base.service';
import { type ServerService as _ServerService } from 'src/services/server.service'; import { type ServerService as _ServerService } from 'src/services/server.service';
import { MaintenanceModeState } from 'src/types'; 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 { getConfig } from 'src/utils/config';
import { createMaintenanceLoginUrl } from 'src/utils/maintenance'; import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc'; import { getExternalDomain } from 'src/utils/misc';
@@ -35,7 +35,7 @@ export class MaintenanceWorkerService {
private appRepository: AppRepository, private appRepository: AppRepository,
private configRepository: ConfigRepository, private configRepository: ConfigRepository,
private systemMetadataRepository: SystemMetadataRepository, private systemMetadataRepository: SystemMetadataRepository,
private maintenanceWorkerRepository: MaintenanceWebsocketRepository, private maintenanceWebsocketRepository: MaintenanceWebsocketRepository,
private maintenanceEphemeralStateRepository: MaintenanceEphemeralStateRepository, private maintenanceEphemeralStateRepository: MaintenanceEphemeralStateRepository,
private storageRepository: StorageRepository, private storageRepository: StorageRepository,
private processRepository: ProcessRepository, private processRepository: ProcessRepository,
@@ -130,6 +130,17 @@ export class MaintenanceWorkerService {
return '/usr/src/app/upload'; 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> { async logSecret(): Promise<void> {
const { server } = await this.getConfig({ withCache: true }); const { server } = await this.getConfig({ withCache: true });
@@ -153,9 +164,9 @@ export class MaintenanceWorkerService {
async status(potentiallyJwt?: string): Promise<MaintenanceStatusResponseDto> { async status(potentiallyJwt?: string): Promise<MaintenanceStatusResponseDto> {
try { try {
await this.login(potentiallyJwt); await this.login(potentiallyJwt);
return this.maintenanceEphemeralStateRepository.getState(); return this.maintenanceEphemeralStateRepository.getStatus();
} catch { } 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> { async endMaintenance(): Promise<void> {
const state: MaintenanceModeState = { isMaintenanceMode: false as const }; const state: MaintenanceModeState = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state); await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
// => corresponds to notification.service.ts#onAppRestart // => corresponds to notification.service.ts#onAppRestart
this.maintenanceWorkerRepository.clientBroadcast('AppRestartV1', state); this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state);
this.maintenanceWorkerRepository.serverSend('AppRestart', state); this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
this.appRepository.exitApp(); this.appRepository.exitApp();
} }
@@ -188,6 +239,31 @@ export class MaintenanceWorkerService {
* Backups * 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[]>> { async listBackups(): Promise<Record<'backups' | 'failedBackups', string[]>> {
return listBackups(this.backupRepos); return listBackups(this.backupRepos);
} }