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:
Michel Heusschen 2023-04-01 22:46:07 +02:00 committed by GitHub
parent d04f340b5b
commit b06ddec2d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 722 additions and 242 deletions

View file

@ -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>;
}

View file

@ -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 }),

View file

@ -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`);
}

View file

@ -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;
}

View file

@ -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(),
};
};

View file

@ -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) {