mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 09:15:35 +03:00
feat: synchronised status, restore db action
This commit is contained in:
@@ -16604,6 +16604,9 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"required": [
|
||||||
|
"action"
|
||||||
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"ManualJobName": {
|
"ManualJobName": {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user