feat(web): clear failed jobs (#5423)

* add clear failed jobs button

* refactor: clean up code

* chore: open api

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Clement Ong 2023-12-05 10:07:20 +08:00 committed by GitHub
parent 933c24ea6f
commit 982183600d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 55 additions and 9 deletions

View file

@ -1785,7 +1785,8 @@ export const JobCommand = {
Start: 'start', Start: 'start',
Pause: 'pause', Pause: 'pause',
Resume: 'resume', Resume: 'resume',
Empty: 'empty' Empty: 'empty',
ClearFailed: 'clear-failed'
} as const; } as const;
export type JobCommand = typeof JobCommand[keyof typeof JobCommand]; export type JobCommand = typeof JobCommand[keyof typeof JobCommand];

View file

@ -27,6 +27,7 @@ class JobCommand {
static const pause = JobCommand._(r'pause'); static const pause = JobCommand._(r'pause');
static const resume = JobCommand._(r'resume'); static const resume = JobCommand._(r'resume');
static const empty = JobCommand._(r'empty'); static const empty = JobCommand._(r'empty');
static const clearFailed = JobCommand._(r'clear-failed');
/// List of all possible values in this [enum][JobCommand]. /// List of all possible values in this [enum][JobCommand].
static const values = <JobCommand>[ static const values = <JobCommand>[
@ -34,6 +35,7 @@ class JobCommand {
pause, pause,
resume, resume,
empty, empty,
clearFailed,
]; ];
static JobCommand? fromJson(dynamic value) => JobCommandTypeTransformer().decode(value); static JobCommand? fromJson(dynamic value) => JobCommandTypeTransformer().decode(value);
@ -76,6 +78,7 @@ class JobCommandTypeTransformer {
case r'pause': return JobCommand.pause; case r'pause': return JobCommand.pause;
case r'resume': return JobCommand.resume; case r'resume': return JobCommand.resume;
case r'empty': return JobCommand.empty; case r'empty': return JobCommand.empty;
case r'clear-failed': return JobCommand.clearFailed;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');

View file

@ -7548,7 +7548,8 @@
"start", "start",
"pause", "pause",
"resume", "resume",
"empty" "empty",
"clear-failed"
], ],
"type": "string" "type": "string"
}, },

View file

@ -18,6 +18,7 @@ export enum JobCommand {
PAUSE = 'pause', PAUSE = 'pause',
RESUME = 'resume', RESUME = 'resume',
EMPTY = 'empty', EMPTY = 'empty',
CLEAR_FAILED = 'clear-failed',
} }
export enum JobName { export enum JobName {

View file

@ -10,6 +10,7 @@ import {
ISystemConfigRepository, ISystemConfigRepository,
JobHandler, JobHandler,
JobItem, JobItem,
QueueCleanType,
} from '../repositories'; } from '../repositories';
import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core'; import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core';
import { JobCommand, JobName, QueueName } from './job.constants'; import { JobCommand, JobName, QueueName } from './job.constants';
@ -49,6 +50,11 @@ export class JobService {
case JobCommand.EMPTY: case JobCommand.EMPTY:
await this.jobRepository.empty(queueName); await this.jobRepository.empty(queueName);
break; break;
case JobCommand.CLEAR_FAILED:
const failedJobs = await this.jobRepository.clear(queueName, QueueCleanType.FAILED);
this.logger.debug(`Cleared failed jobs: ${failedJobs}`);
break;
} }
return this.getJobStatus(queueName); return this.getJobStatus(queueName);

View file

@ -26,6 +26,10 @@ export interface QueueStatus {
isPaused: boolean; isPaused: boolean;
} }
export enum QueueCleanType {
FAILED = 'failed',
}
export type JobItem = export type JobItem =
// Transcoding // Transcoding
| { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob } | { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob }
@ -120,6 +124,7 @@ export interface IJobRepository {
pause(name: QueueName): Promise<void>; pause(name: QueueName): Promise<void>;
resume(name: QueueName): Promise<void>; resume(name: QueueName): Promise<void>;
empty(name: QueueName): Promise<void>; empty(name: QueueName): Promise<void>;
clear(name: QueueName, type: QueueCleanType): Promise<string[]>;
getQueueStatus(name: QueueName): Promise<QueueStatus>; getQueueStatus(name: QueueName): Promise<QueueStatus>;
getJobCounts(name: QueueName): Promise<JobCounts>; getJobCounts(name: QueueName): Promise<JobCounts>;
} }

View file

@ -1,4 +1,13 @@
import { IJobRepository, JobCounts, JobItem, JobName, JOBS_TO_QUEUE, QueueName, QueueStatus } from '@app/domain'; import {
IJobRepository,
JobCounts,
JobItem,
JobName,
JOBS_TO_QUEUE,
QueueCleanType,
QueueName,
QueueStatus,
} from '@app/domain';
import { getQueueToken } from '@nestjs/bullmq'; import { getQueueToken } from '@nestjs/bullmq';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
@ -91,6 +100,10 @@ export class JobRepository implements IJobRepository {
return this.getQueue(name).drain(); return this.getQueue(name).drain();
} }
clear(name: QueueName, type: QueueCleanType) {
return this.getQueue(name).clean(0, 1000, type);
}
getJobCounts(name: QueueName): Promise<JobCounts> { getJobCounts(name: QueueName): Promise<JobCounts> {
return this.getQueue(name).getJobCounts( return this.getQueue(name).getJobCounts(
'active', 'active',

View file

@ -13,5 +13,6 @@ export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
queue: jest.fn().mockImplementation(() => Promise.resolve()), queue: jest.fn().mockImplementation(() => Promise.resolve()),
getQueueStatus: jest.fn(), getQueueStatus: jest.fn(),
getJobCounts: jest.fn(), getJobCounts: jest.fn(),
clear: jest.fn(),
}; };
}; };

View file

@ -76,6 +76,7 @@ export const testApp = {
getQueueStatus: jest.fn(), getQueueStatus: jest.fn(),
getJobCounts: jest.fn(), getJobCounts: jest.fn(),
pause: jest.fn(), pause: jest.fn(),
clear: jest.fn(),
} as IJobRepository) } as IJobRepository)
.compile(); .compile();

View file

@ -1785,7 +1785,8 @@ export const JobCommand = {
Start: 'start', Start: 'start',
Pause: 'pause', Pause: 'pause',
Resume: 'resume', Resume: 'resume',
Empty: 'empty' Empty: 'empty',
ClearFailed: 'clear-failed'
} as const; } as const;
export type JobCommand = typeof JobCommand[keyof typeof JobCommand]; export type JobCommand = typeof JobCommand[keyof typeof JobCommand];

View file

@ -5,6 +5,7 @@
import Badge from '$lib/components/elements/badge.svelte'; import Badge from '$lib/components/elements/badge.svelte';
import JobTileButton from './job-tile-button.svelte'; import JobTileButton from './job-tile-button.svelte';
import JobTileStatus from './job-tile-status.svelte'; import JobTileStatus from './job-tile-status.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { import {
mdiAlertCircle, mdiAlertCircle,
@ -55,12 +56,23 @@
<div class="flex gap-2"> <div class="flex gap-2">
{#if jobCounts.failed > 0} {#if jobCounts.failed > 0}
<Badge color="primary"> <Badge color="primary">
{jobCounts.failed.toLocaleString($locale)} failed <span class="text-sm">
{jobCounts.failed.toLocaleString($locale)} failed
</span>
<Button
size="tiny"
shadow={false}
on:click={() => dispatch('command', { command: JobCommand.ClearFailed, force: false })}
>
<Icon path={mdiClose} size="18" />
</Button>
</Badge> </Badge>
{/if} {/if}
{#if jobCounts.delayed > 0} {#if jobCounts.delayed > 0 || true}
<Badge color="secondary"> <Badge color="secondary">
{jobCounts.delayed.toLocaleString($locale)} delayed <span class="text-sm">
{jobCounts.delayed.toLocaleString($locale)} delayed
</span>
</Badge> </Badge>
{/if} {/if}
</div> </div>

View file

@ -14,7 +14,7 @@
</script> </script>
<span <span
class="inline-block h-min whitespace-nowrap px-4 pb-[0.55em] pt-[0.55em] text-center align-baseline text-xs leading-none {colorClasses[ class="inline-block h-min whitespace-nowrap px-3 py-1 text-center align-baseline text-xs leading-none {colorClasses[
color color
]}" ]}"
class:rounded-md={rounded === true} class:rounded-md={rounded === true}

View file

@ -11,7 +11,7 @@
| 'transparent-gray' | 'transparent-gray'
| 'dark-gray' | 'dark-gray'
| 'overlay-primary'; | 'overlay-primary';
export type Size = 'icon' | 'link' | 'sm' | 'base' | 'lg'; export type Size = 'tiny' | 'icon' | 'link' | 'sm' | 'base' | 'lg';
export type Rounded = 'lg' | '3xl' | 'full' | false; export type Rounded = 'lg' | '3xl' | 'full' | false;
export type Shadow = 'md' | false; export type Shadow = 'md' | false;
</script> </script>
@ -46,6 +46,7 @@
}; };
const sizeClasses: Record<Size, string> = { const sizeClasses: Record<Size, string> = {
tiny: 'p-0 ml-2 mr-0 align-top',
icon: 'p-2.5', icon: 'p-2.5',
link: 'p-2 font-medium', link: 'p-2 font-medium',
sm: 'px-4 py-2 text-sm font-medium', sm: 'px-4 py-2 text-sm font-medium',