From 53851e4c9dcbfa7946456dd2b400bf83d81a5c19 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 22 Nov 2025 09:34:54 -0500 Subject: [PATCH 1/2] Refactor notification jobs to shared background service --- .../resolvers/mutation/mutation.model.ts | 8 + .../resolvers/mutation/mutation.resolver.ts | 6 + .../notifications/notifications.model.ts | 47 +++++- .../notifications.mutations.resolver.ts | 61 ++++++++ .../notifications/notifications.resolver.ts | 6 + .../notifications/notifications.service.ts | 88 ++++++++++- .../graph/resolvers/resolvers.module.ts | 4 + .../graph/services/background-jobs.service.ts | 94 ++++++++++++ web/src/components/Notifications/Item.vue | 1 - web/src/components/Notifications/Sidebar.vue | 138 ++++++++++++++++-- .../graphql/notification.query.ts | 65 ++++++--- web/src/locales/en.json | 4 + 12 files changed, 484 insertions(+), 38 deletions(-) create mode 100644 api/src/unraid-api/graph/resolvers/notifications/notifications.mutations.resolver.ts create mode 100644 api/src/unraid-api/graph/services/background-jobs.service.ts diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts index 73dad03e19..fd95f73990 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -40,6 +40,11 @@ export class RCloneMutations { deleteRCloneRemote!: boolean; } +@ObjectType({ + description: 'Notification related mutations', +}) +export class NotificationMutations {} + @ObjectType() export class RootMutations { @Field(() => ArrayMutations, { description: 'Array related mutations' }) @@ -59,4 +64,7 @@ export class RootMutations { @Field(() => RCloneMutations, { description: 'RClone related mutations' }) rclone: RCloneMutations = new RCloneMutations(); + + @Field(() => NotificationMutations, { description: 'Notification related mutations' }) + notifications: NotificationMutations = new NotificationMutations(); } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts index 42a9cb126a..785d1098a7 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts @@ -7,6 +7,7 @@ import { ParityCheckMutations, RCloneMutations, RootMutations, + NotificationMutations, VmMutations, } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; @@ -41,4 +42,9 @@ export class RootMutationsResolver { rclone(): RCloneMutations { return new RCloneMutations(); } + + @Mutation(() => NotificationMutations, { name: 'notifications' }) + notifications(): NotificationMutations { + return new NotificationMutations(); + } } diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts index 069620cd49..6ec8d4a406 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts @@ -1,4 +1,4 @@ -import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; +import { Field, ID, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Node } from '@unraid/shared/graphql.model.js'; import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; @@ -14,6 +14,18 @@ export enum NotificationImportance { WARNING = 'WARNING', } +export enum NotificationJobState { + QUEUED = 'QUEUED', + RUNNING = 'RUNNING', + SUCCEEDED = 'SUCCEEDED', + FAILED = 'FAILED', +} + +export enum NotificationJobOperation { + ARCHIVE_ALL = 'ARCHIVE_ALL', + DELETE = 'DELETE', +} + // Register enums with GraphQL registerEnumType(NotificationType, { name: 'NotificationType', @@ -23,6 +35,14 @@ registerEnumType(NotificationImportance, { name: 'NotificationImportance', }); +registerEnumType(NotificationJobState, { + name: 'NotificationJobState', +}); + +registerEnumType(NotificationJobOperation, { + name: 'NotificationJobOperation', +}); + @InputType('NotificationFilter') export class NotificationFilter { @Field(() => NotificationImportance, { nullable: true }) @@ -164,4 +184,29 @@ export class Notifications extends Node { @Field(() => [Notification]) @IsNotEmpty() list!: Notification[]; + + @Field(() => NotificationJob, { nullable: true }) + job?: NotificationJob; } + +@ObjectType() +export class NotificationJob { + @Field(() => ID) + id!: string; + + @Field(() => NotificationJobOperation) + operation!: NotificationJobOperation; + + @Field(() => NotificationJobState) + state!: NotificationJobState; + + @Field(() => Int) + processed!: number; + + @Field(() => Int) + total!: number; + + @Field({ nullable: true }) + error?: string | null; +} + diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.mutations.resolver.ts new file mode 100644 index 0000000000..7d3735a94d --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.mutations.resolver.ts @@ -0,0 +1,61 @@ +import { Args, ResolveField, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { AppError } from '@app/core/errors/app-error.js'; +import { + NotificationImportance, + NotificationJob, + NotificationJobState, + NotificationMutations, + NotificationOverview, + NotificationType, +} from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js'; +import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; + +@Resolver(() => NotificationMutations) +export class NotificationMutationsResolver { + constructor(private readonly notificationsService: NotificationsService) {} + + @ResolveField(() => NotificationOverview) + @UsePermissions({ + action: AuthAction.DELETE_ANY, + resource: Resource.NOTIFICATIONS, + }) + public async delete( + @Args('id', { type: () => PrefixedID }) id: string, + @Args('type', { type: () => NotificationType }) type: NotificationType + ): Promise { + const { overview } = await this.notificationsService.deleteNotification({ id, type }); + return overview; + } + + @ResolveField(() => NotificationJob) + @UsePermissions({ + action: AuthAction.DELETE_ANY, + resource: Resource.NOTIFICATIONS, + }) + public async startArchiveAll( + @Args('importance', { type: () => NotificationImportance, nullable: true }) + importance?: NotificationImportance + ): Promise { + return this.notificationsService.startArchiveAllJob(importance); + } + + @ResolveField(() => NotificationJob) + @UsePermissions({ + action: AuthAction.DELETE_ANY, + resource: Resource.NOTIFICATIONS, + }) + public async startDeleteAll( + @Args('type', { type: () => NotificationType, nullable: true }) type?: NotificationType + ): Promise { + const job = await this.notificationsService.startDeleteAllJob(type); + if (job.state === NotificationJobState.FAILED) { + throw new AppError(job.error ?? 'Failed to delete notifications', 500); + } + return job; + } +} diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts index fe6e56ad6b..8f903d53c1 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -11,6 +11,7 @@ import { NotificationData, NotificationFilter, NotificationImportance, + NotificationJob, NotificationOverview, Notifications, NotificationType, @@ -41,6 +42,11 @@ export class NotificationsResolver { return this.notificationsService.getOverview(); } + @ResolveField(() => NotificationJob, { nullable: true }) + public job(@Args('id') jobId: string): NotificationJob { + return this.notificationsService.getJob(jobId); + } + @ResolveField(() => [Notification]) public async list( @Args('filter', { type: () => NotificationFilter }) diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts index 6ec780d666..1c3431e585 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -24,10 +24,14 @@ import { NotificationData, NotificationFilter, NotificationImportance, + NotificationJob, + NotificationJobOperation, + NotificationJobState, NotificationOverview, NotificationType, } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js'; import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; +import { BackgroundJobsService } from '@app/unraid-api/graph/services/background-jobs.service.js'; import { SortFn } from '@app/unraid-api/types/util.js'; import { batchProcess, formatDatetime, isFulfilled, isRejected, unraidTimestamp } from '@app/utils.js'; @@ -55,7 +59,36 @@ export class NotificationsService { }, }; - constructor() { + private createJob(operation: NotificationJobOperation, total: number): NotificationJob { + const state = total === 0 ? NotificationJobState.SUCCEEDED : NotificationJobState.QUEUED; + return this.backgroundJobs.createJob({ + operation, + total, + initialState: state, + prefix: operation.toLowerCase(), + }); + } + + private updateJob(job: NotificationJob) { + this.backgroundJobs.updateJob(job); + } + + private async runJob(job: NotificationJob, work: () => Promise) { + await this.backgroundJobs.runJob({ + job, + work, + runningState: NotificationJobState.RUNNING, + successState: NotificationJobState.SUCCEEDED, + failureState: NotificationJobState.FAILED, + logContext: 'notification-job', + }); + } + + public getJob(jobId: string): NotificationJob { + return this.backgroundJobs.getJob(jobId); + } + + constructor(private readonly backgroundJobs: BackgroundJobsService) { this.path = getters.dynamix().notify!.path; void this.getNotificationsWatcher(this.path); } @@ -323,6 +356,30 @@ export class NotificationsService { return this.getOverview(); } + public async startDeleteAllJob(type?: NotificationType): Promise { + const targets = type ? [type] : [NotificationType.ARCHIVE, NotificationType.UNREAD]; + const queue: Array<{ id: string; type: NotificationType }> = []; + + for (const targetType of targets) { + const ids = await this.listFilesInFolder(this.paths()[targetType]); + ids.forEach((id) => queue.push({ id, type: targetType })); + } + + const job = this.createJob(NotificationJobOperation.DELETE, queue.length); + + setImmediate(() => + this.runJob(job, async () => { + for (const entry of queue) { + await this.deleteNotification({ id: entry.id, type: entry.type }); + job.processed += 1; + this.updateJob(job); + } + }) + ); + + return job; + } + /** * Deletes all notifications from disk while preserving the directory structure. * Resets overview stats to zero. @@ -485,6 +542,35 @@ export class NotificationsService { return { ...stats, overview: overviewSnapshot }; } + public async startArchiveAllJob(importance?: NotificationImportance): Promise { + const { UNREAD } = this.paths(); + const unreads = await this.listFilesInFolder(UNREAD); + const [notifications] = await this.loadNotificationsFromPaths( + unreads, + importance ? { importance } : {} + ); + const job = this.createJob(NotificationJobOperation.ARCHIVE_ALL, notifications.length); + + setImmediate(() => + this.runJob(job, async () => { + const overviewSnapshot = this.getOverview(); + const archive = this.moveNotification({ + from: NotificationType.UNREAD, + to: NotificationType.ARCHIVE, + snapshot: overviewSnapshot, + }); + + for (const notification of notifications) { + await archive(notification); + job.processed += 1; + this.updateJob(job); + } + }) + ); + + return job; + } + public async unarchiveAll(importance?: NotificationImportance) { const { ARCHIVE } = this.paths(); diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 751d42891e..e3b353244a 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -15,6 +15,7 @@ import { InfoModule } from '@app/unraid-api/graph/resolvers/info/info.module.js' import { LogsModule } from '@app/unraid-api/graph/resolvers/logs/logs.module.js'; import { MetricsModule } from '@app/unraid-api/graph/resolvers/metrics/metrics.module.js'; import { RootMutationsResolver } from '@app/unraid-api/graph/resolvers/mutation/mutation.resolver.js'; +import { NotificationMutationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.mutations.resolver.js'; import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.resolver.js'; import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; import { OnlineResolver } from '@app/unraid-api/graph/resolvers/online/online.resolver.js'; @@ -29,6 +30,7 @@ import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js'; import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js'; import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js'; +import { BackgroundJobsService } from '@app/unraid-api/graph/services/background-jobs.service.js'; import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js'; import { ServicesResolver } from '@app/unraid-api/graph/services/services.resolver.js'; import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js'; @@ -57,8 +59,10 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; ConfigResolver, FlashResolver, MeResolver, + NotificationMutationsResolver, NotificationsResolver, NotificationsService, + BackgroundJobsService, OnlineResolver, OwnerResolver, RegistrationResolver, diff --git a/api/src/unraid-api/graph/services/background-jobs.service.ts b/api/src/unraid-api/graph/services/background-jobs.service.ts new file mode 100644 index 0000000000..8b6a4c9952 --- /dev/null +++ b/api/src/unraid-api/graph/services/background-jobs.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { AppError } from '@app/core/errors/app-error.js'; + +export interface BackgroundJob { + id: string; + operation: Operation; + state: State; + processed: number; + total: number; + error?: string | null; + meta?: Record; +} + +interface CreateJobOptions { + operation: Operation; + total: number; + initialState: State; + prefix?: string; + meta?: Record; +} + +interface RunJobOptions { + job: BackgroundJob; + work: () => Promise; + runningState: State; + successState: State; + failureState: State; + logContext?: string; +} + +@Injectable() +export class BackgroundJobsService { + private readonly logger = new Logger(BackgroundJobsService.name); + private readonly jobs = new Map(); + + public createJob(options: CreateJobOptions) { + const { operation, total, initialState, prefix, meta } = options; + const idBase = typeof operation === 'string' ? operation.toLowerCase() : 'job'; + const id = `${prefix ?? idBase}-${Date.now().toString(36)}`; + + const job: BackgroundJob = { + id, + operation, + state: initialState, + processed: 0, + total, + error: null, + meta, + }; + + this.jobs.set(job.id, job); + return job; + } + + public updateJob(job: BackgroundJob) { + this.jobs.set(job.id, { ...job }); + return this.jobs.get(job.id) as BackgroundJob; + } + + public getJob(jobId: string) { + const job = this.jobs.get(jobId) as BackgroundJob | undefined; + if (!job) { + throw new AppError(`Background job ${jobId} not found`, 404); + } + return job; + } + + public async runJob(options: RunJobOptions) { + const { job, work, runningState, successState, failureState, logContext } = options; + if (job.state === successState) { + return job; + } + + job.state = runningState; + this.updateJob(job); + + try { + await work(); + job.state = successState; + } catch (error) { + job.state = failureState; + job.error = error instanceof Error ? error.message : 'Unknown error while processing job'; + this.logger.error( + `[${logContext ?? 'background-job'}] failed ${job.id}: ${job.error}`, + error as Error + ); + } finally { + this.updateJob(job); + } + + return job; + } +} diff --git a/web/src/components/Notifications/Item.vue b/web/src/components/Notifications/Item.vue index ede4b1afe3..acb54a3605 100644 --- a/web/src/components/Notifications/Item.vue +++ b/web/src/components/Notifications/Item.vue @@ -142,7 +142,6 @@ const reformattedTimestamp = computed(() => { {{ t('notifications.item.archive') }}