mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat(server/web): jobs clear button + queue status (#2144)
* feat(server/web): jobs clear button + queue status * adjust design and colors * Adjust some styling * show status next to buttons instead of on top * Update rounded corner for badge --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { AllJobStatusResponseDto, JobCommandDto, JobIdDto, JobService } from '@app/domain';
|
||||
import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto, JobIdDto, JobService } from '@app/domain';
|
||||
import { Body, Controller, Get, Param, Put, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||
@@ -16,7 +16,8 @@ export class JobController {
|
||||
}
|
||||
|
||||
@Put('/:jobId')
|
||||
sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise<void> {
|
||||
return this.service.handleCommand(jobId, dto);
|
||||
async sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
|
||||
await this.service.handleCommand(jobId, dto);
|
||||
return await this.service.getJobStatus(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +541,14 @@
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
@@ -4088,32 +4095,62 @@
|
||||
"paused"
|
||||
]
|
||||
},
|
||||
"QueueStatusDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"isActive": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isPaused": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"isActive",
|
||||
"isPaused"
|
||||
]
|
||||
},
|
||||
"JobStatusDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jobCounts": {
|
||||
"$ref": "#/components/schemas/JobCountsDto"
|
||||
},
|
||||
"queueStatus": {
|
||||
"$ref": "#/components/schemas/QueueStatusDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"jobCounts",
|
||||
"queueStatus"
|
||||
]
|
||||
},
|
||||
"AllJobStatusResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"thumbnail-generation-queue": {
|
||||
"$ref": "#/components/schemas/JobCountsDto"
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
},
|
||||
"metadata-extraction-queue": {
|
||||
"$ref": "#/components/schemas/JobCountsDto"
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
},
|
||||
"video-conversion-queue": {
|
||||
"$ref": "#/components/schemas/JobCountsDto"
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
},
|
||||
"object-tagging-queue": {
|
||||
"$ref": "#/components/schemas/JobCountsDto"
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
},
|
||||
"clip-encoding-queue": {
|
||||
"$ref": "#/components/schemas/JobCountsDto"
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
},
|
||||
"storage-template-migration-queue": {
|
||||
"$ref": "#/components/schemas/JobCountsDto"
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
},
|
||||
"background-task-queue": {
|
||||
"$ref": "#/components/schemas/JobCountsDto"
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
},
|
||||
"search-queue": {
|
||||
"$ref": "#/components/schemas/JobCountsDto"
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -18,6 +18,11 @@ export interface JobCounts {
|
||||
paused: number;
|
||||
}
|
||||
|
||||
export interface QueueStatus {
|
||||
isActive: boolean;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
export type JobItem =
|
||||
// Asset Upload
|
||||
| { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
|
||||
@@ -73,6 +78,6 @@ export interface IJobRepository {
|
||||
pause(name: QueueName): Promise<void>;
|
||||
resume(name: QueueName): Promise<void>;
|
||||
empty(name: QueueName): Promise<void>;
|
||||
isActive(name: QueueName): Promise<boolean>;
|
||||
getQueueStatus(name: QueueName): Promise<QueueStatus>;
|
||||
getJobCounts(name: QueueName): Promise<JobCounts>;
|
||||
}
|
||||
|
||||
@@ -25,72 +25,35 @@ describe(JobService.name, () => {
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
});
|
||||
jobMock.getQueueStatus.mockResolvedValue({
|
||||
isActive: true,
|
||||
isPaused: true,
|
||||
});
|
||||
|
||||
const expectedJobStatus = {
|
||||
jobCounts: {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
},
|
||||
queueStatus: {
|
||||
isActive: true,
|
||||
isPaused: true,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(sut.getAllJobsStatus()).resolves.toEqual({
|
||||
'background-task-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
},
|
||||
'clip-encoding-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
},
|
||||
'metadata-extraction-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
},
|
||||
'object-tagging-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
},
|
||||
'search-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
},
|
||||
'storage-template-migration-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
},
|
||||
'thumbnail-generation-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
},
|
||||
'video-conversion-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
paused: 1,
|
||||
},
|
||||
'background-task-queue': expectedJobStatus,
|
||||
'clip-encoding-queue': expectedJobStatus,
|
||||
'metadata-extraction-queue': expectedJobStatus,
|
||||
'object-tagging-queue': expectedJobStatus,
|
||||
'search-queue': expectedJobStatus,
|
||||
'storage-template-migration-queue': expectedJobStatus,
|
||||
'thumbnail-generation-queue': expectedJobStatus,
|
||||
'video-conversion-queue': expectedJobStatus,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -115,7 +78,7 @@ describe(JobService.name, () => {
|
||||
});
|
||||
|
||||
it('should not start a job that is already running', async () => {
|
||||
jobMock.isActive.mockResolvedValue(true);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false });
|
||||
|
||||
await expect(
|
||||
sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }),
|
||||
@@ -125,7 +88,7 @@ describe(JobService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle a start video conversion command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false });
|
||||
|
||||
@@ -133,7 +96,7 @@ describe(JobService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle a start storage template migration command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false });
|
||||
|
||||
@@ -141,7 +104,7 @@ describe(JobService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle a start object tagging command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.OBJECT_TAGGING, { command: JobCommand.START, force: false });
|
||||
|
||||
@@ -149,7 +112,7 @@ describe(JobService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle a start clip encoding command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.CLIP_ENCODING, { command: JobCommand.START, force: false });
|
||||
|
||||
@@ -157,7 +120,7 @@ describe(JobService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle a start metadata extraction command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false });
|
||||
|
||||
@@ -165,7 +128,7 @@ describe(JobService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle a start thumbnail generation command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false });
|
||||
|
||||
@@ -173,7 +136,7 @@ describe(JobService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw a bad request when an invalid queue is used', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await expect(
|
||||
sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { assertMachineLearningEnabled } from '../domain.constant';
|
||||
import { JobCommandDto } from './dto';
|
||||
import { JobCommand, JobName, QueueName } from './job.constants';
|
||||
import { IJobRepository } from './job.repository';
|
||||
import { AllJobStatusResponseDto } from './response-dto';
|
||||
import { AllJobStatusResponseDto, JobStatusDto } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class JobService {
|
||||
@@ -29,16 +29,25 @@ export class JobService {
|
||||
}
|
||||
}
|
||||
|
||||
async getJobStatus(queueName: QueueName): Promise<JobStatusDto> {
|
||||
const [jobCounts, queueStatus] = await Promise.all([
|
||||
this.jobRepository.getJobCounts(queueName),
|
||||
this.jobRepository.getQueueStatus(queueName),
|
||||
]);
|
||||
|
||||
return { jobCounts, queueStatus };
|
||||
}
|
||||
|
||||
async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
|
||||
const response = new AllJobStatusResponseDto();
|
||||
for (const queueName of Object.values(QueueName)) {
|
||||
response[queueName] = await this.jobRepository.getJobCounts(queueName);
|
||||
response[queueName] = await this.getJobStatus(queueName);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async start(name: QueueName, { force }: JobCommandDto): Promise<void> {
|
||||
const isActive = await this.jobRepository.isActive(name);
|
||||
const { isActive } = await this.jobRepository.getQueueStatus(name);
|
||||
if (isActive) {
|
||||
throw new BadRequestException(`Job is already running`);
|
||||
}
|
||||
|
||||
@@ -16,28 +16,41 @@ export class JobCountsDto {
|
||||
paused!: number;
|
||||
}
|
||||
|
||||
export class AllJobStatusResponseDto implements Record<QueueName, JobCountsDto> {
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.THUMBNAIL_GENERATION]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.METADATA_EXTRACTION]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.VIDEO_CONVERSION]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.OBJECT_TAGGING]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.CLIP_ENCODING]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.BACKGROUND_TASK]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.SEARCH]!: JobCountsDto;
|
||||
export class QueueStatusDto {
|
||||
isActive!: boolean;
|
||||
isPaused!: boolean;
|
||||
}
|
||||
|
||||
export class JobStatusDto {
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
jobCounts!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: QueueStatusDto })
|
||||
queueStatus!: QueueStatusDto;
|
||||
}
|
||||
|
||||
export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto> {
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.THUMBNAIL_GENERATION]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.METADATA_EXTRACTION]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.VIDEO_CONVERSION]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.OBJECT_TAGGING]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.CLIP_ENCODING]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.BACKGROUND_TASK]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.SEARCH]!: JobStatusDto;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
|
||||
pause: jest.fn(),
|
||||
resume: jest.fn(),
|
||||
queue: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
isActive: jest.fn(),
|
||||
getQueueStatus: jest.fn(),
|
||||
getJobCounts: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
JobItem,
|
||||
JobName,
|
||||
QueueName,
|
||||
QueueStatus,
|
||||
} from '@app/domain';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
@@ -36,9 +37,13 @@ export class JobRepository implements IJobRepository {
|
||||
@InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
|
||||
) {}
|
||||
|
||||
async isActive(name: QueueName): Promise<boolean> {
|
||||
const counts = await this.getJobCounts(name);
|
||||
return !!counts.active;
|
||||
async getQueueStatus(name: QueueName): Promise<QueueStatus> {
|
||||
const queue = this.queueMap[name];
|
||||
|
||||
return {
|
||||
isActive: !!(await queue.getActiveCount()),
|
||||
isPaused: await queue.isPaused(),
|
||||
};
|
||||
}
|
||||
|
||||
pause(name: QueueName) {
|
||||
|
||||
Reference in New Issue
Block a user