Browse Source

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>
Clement Ong 1 year ago
parent
commit
982183600d

+ 2 - 1
cli/src/api/open-api/api.ts

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

+ 3 - 0
mobile/openapi/lib/model/job_command.dart

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

+ 2 - 1
server/immich-openapi-specs.json

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

+ 1 - 0
server/src/domain/job/job.constants.ts

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

+ 6 - 0
server/src/domain/job/job.service.ts

@@ -10,6 +10,7 @@ import {
   ISystemConfigRepository,
   JobHandler,
   JobItem,
+  QueueCleanType,
 } from '../repositories';
 import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core';
 import { JobCommand, JobName, QueueName } from './job.constants';
@@ -49,6 +50,11 @@ export class JobService {
       case JobCommand.EMPTY:
         await this.jobRepository.empty(queueName);
         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);

+ 5 - 0
server/src/domain/repositories/job.repository.ts

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

+ 14 - 1
server/src/infra/repositories/job.repository.ts

@@ -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 { Injectable, Logger } from '@nestjs/common';
 import { ModuleRef } from '@nestjs/core';
@@ -91,6 +100,10 @@ export class JobRepository implements IJobRepository {
     return this.getQueue(name).drain();
   }
 
+  clear(name: QueueName, type: QueueCleanType) {
+    return this.getQueue(name).clean(0, 1000, type);
+  }
+
   getJobCounts(name: QueueName): Promise<JobCounts> {
     return this.getQueue(name).getJobCounts(
       'active',

+ 1 - 0
server/test/repositories/job.repository.mock.ts

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

+ 1 - 0
server/test/test-utils.ts

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

+ 2 - 1
web/src/api/open-api/api.ts

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

+ 15 - 3
web/src/lib/components/admin-page/jobs/job-tile.svelte

@@ -5,6 +5,7 @@
   import Badge from '$lib/components/elements/badge.svelte';
   import JobTileButton from './job-tile-button.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 {
     mdiAlertCircle,
@@ -55,12 +56,23 @@
         <div class="flex gap-2">
           {#if jobCounts.failed > 0}
             <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>
           {/if}
-          {#if jobCounts.delayed > 0}
+          {#if jobCounts.delayed > 0 || true}
             <Badge color="secondary">
-              {jobCounts.delayed.toLocaleString($locale)} delayed
+              <span class="text-sm">
+                {jobCounts.delayed.toLocaleString($locale)} delayed
+              </span>
             </Badge>
           {/if}
         </div>

+ 1 - 1
web/src/lib/components/elements/badge.svelte

@@ -14,7 +14,7 @@
 </script>
 
 <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
   ]}"
   class:rounded-md={rounded === true}

+ 2 - 1
web/src/lib/components/elements/buttons/button.svelte

@@ -11,7 +11,7 @@
     | 'transparent-gray'
     | 'dark-gray'
     | '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 Shadow = 'md' | false;
 </script>
@@ -46,6 +46,7 @@
   };
 
   const sizeClasses: Record<Size, string> = {
+    tiny: 'p-0 ml-2 mr-0 align-top',
     icon: 'p-2.5',
     link: 'p-2 font-medium',
     sm: 'px-4 py-2 text-sm font-medium',