Compare commits

...

2 Commits

12 changed files with 497 additions and 41 deletions

View File

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

View File

@@ -4,6 +4,7 @@ import {
ApiKeyMutations,
ArrayMutations,
DockerMutations,
NotificationMutations,
ParityCheckMutations,
RCloneMutations,
RootMutations,
@@ -41,4 +42,9 @@ export class RootMutationsResolver {
rclone(): RCloneMutations {
return new RCloneMutations();
}
@Mutation(() => NotificationMutations, { name: 'notifications' })
notifications(): NotificationMutations {
return new NotificationMutations();
}
}

View File

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

View File

@@ -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<NotificationOverview> {
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<NotificationJob> {
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<NotificationJob> {
const job = await this.notificationsService.startDeleteAllJob(type);
if (job.state === NotificationJobState.FAILED) {
throw new AppError(job.error ?? 'Failed to delete notifications', 500);
}
return job;
}
}

View File

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

View File

@@ -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<NotificationJobOperation, NotificationJobState>({
operation,
total,
initialState: state,
prefix: operation.toLowerCase(),
});
}
private updateJob(job: NotificationJob) {
this.backgroundJobs.updateJob(job);
}
private async runJob(job: NotificationJob, work: () => Promise<void>) {
await this.backgroundJobs.runJob<NotificationJobState>({
job,
work,
runningState: NotificationJobState.RUNNING,
successState: NotificationJobState.SUCCEEDED,
failureState: NotificationJobState.FAILED,
logContext: 'notification-job',
});
}
public getJob(jobId: string): NotificationJob {
return this.backgroundJobs.getJob<NotificationJobOperation, NotificationJobState>(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<NotificationJob> {
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<NotificationJob> {
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();

View File

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

View File

@@ -0,0 +1,94 @@
import { Injectable, Logger } from '@nestjs/common';
import { AppError } from '@app/core/errors/app-error.js';
export interface BackgroundJob<Operation = string, State = string> {
id: string;
operation: Operation;
state: State;
processed: number;
total: number;
error?: string | null;
meta?: Record<string, unknown>;
}
interface CreateJobOptions<Operation, State> {
operation: Operation;
total: number;
initialState: State;
prefix?: string;
meta?: Record<string, unknown>;
}
interface RunJobOptions<State> {
job: BackgroundJob<unknown, State>;
work: () => Promise<void>;
runningState: State;
successState: State;
failureState: State;
logContext?: string;
}
@Injectable()
export class BackgroundJobsService {
private readonly logger = new Logger(BackgroundJobsService.name);
private readonly jobs = new Map<string, BackgroundJob>();
public createJob<Operation, State>(options: CreateJobOptions<Operation, State>) {
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<Operation, State> = {
id,
operation,
state: initialState,
processed: 0,
total,
error: null,
meta,
};
this.jobs.set(job.id, job);
return job;
}
public updateJob<Operation, State>(job: BackgroundJob<Operation, State>) {
this.jobs.set(job.id, { ...job });
return this.jobs.get(job.id) as BackgroundJob<Operation, State>;
}
public getJob<Operation, State>(jobId: string) {
const job = this.jobs.get(jobId) as BackgroundJob<Operation, State> | undefined;
if (!job) {
throw new AppError(`Background job ${jobId} not found`, 404);
}
return job;
}
public async runJob<State>(options: RunJobOptions<State>) {
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;
}
}

View File

@@ -142,7 +142,6 @@ const reformattedTimestamp = computed<string>(() => {
<span class="text-sm">{{ t('notifications.item.archive') }}</span>
</Button>
<Button
v-if="type === NotificationType.ARCHIVE"
:disabled="deleteNotification.loading"
@click="() => deleteNotification.mutate({ id: props.id, type: props.type })"
>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
import { useApolloClient, useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
import {
Button,
@@ -24,10 +24,12 @@ import { Settings } from 'lucide-vue-next';
import ConfirmDialog from '~/components/ConfirmDialog.vue';
import {
archiveAllNotifications,
deleteArchivedNotifications,
NOTIFICATION_FRAGMENT,
NOTIFICATION_JOB_FRAGMENT,
notificationJobStatus,
notificationsOverview,
resetOverview,
startDeleteNotifications,
} from '~/components/Notifications/graphql/notification.query';
import {
notificationAddedSubscription,
@@ -37,15 +39,100 @@ import NotificationsIndicator from '~/components/Notifications/Indicator.vue';
import NotificationsList from '~/components/Notifications/List.vue';
import { useTrackLatestSeenNotification } from '~/composables/api/use-notifications';
import { useFragment } from '~/composables/gql';
import { NotificationImportance as Importance, NotificationType } from '~/composables/gql/graphql';
import {
NotificationImportance as Importance,
NotificationJobState,
NotificationType,
} from '~/composables/gql/graphql';
import { useConfirm } from '~/composables/useConfirm';
const { mutate: archiveAll, loading: loadingArchiveAll } = useMutation(archiveAllNotifications);
const { mutate: deleteArchives, loading: loadingDeleteAll } = useMutation(deleteArchivedNotifications);
const { mutate: startArchiveAllJob, loading: loadingArchiveAll } = useMutation(archiveAllNotifications);
const { mutate: startDeleteAll, loading: loadingDeleteAll } = useMutation(startDeleteNotifications);
const { client } = useApolloClient();
const { mutate: recalculateOverview } = useMutation(resetOverview);
const { confirm } = useConfirm();
const importance = ref<Importance | undefined>(undefined);
const jobPollers: Record<
'archiveAll' | 'deleteArchived' | 'deleteUnread',
ReturnType<typeof setInterval> | null
> = {
archiveAll: null,
deleteArchived: null,
deleteUnread: null,
};
const activeJobs = reactive<
Record<
'archiveAll' | 'deleteArchived' | 'deleteUnread',
{
id: string;
state: NotificationJobState;
processed: number;
total: number;
error?: string | null;
} | null
>
>({
archiveAll: null,
deleteArchived: null,
deleteUnread: null,
});
const activeStates = [NotificationJobState.QUEUED, NotificationJobState.RUNNING];
const stopPolling = (key: keyof typeof jobPollers) => {
const interval = jobPollers[key];
if (interval) {
clearInterval(interval);
jobPollers[key] = null;
}
};
const setJob = (key: keyof typeof jobPollers, job?: unknown) => {
const parsed = job ? useFragment(NOTIFICATION_JOB_FRAGMENT, job) : null;
activeJobs[key] = parsed;
if (!parsed || !activeStates.includes(parsed.state)) {
stopPolling(key);
}
};
const pollJob = (key: keyof typeof jobPollers) => {
const job = activeJobs[key];
if (!job) return;
stopPolling(key);
if (!activeStates.includes(job.state)) return;
jobPollers[key] = setInterval(async () => {
const { data } = await client.query({
query: notificationJobStatus,
variables: { id: job.id },
fetchPolicy: 'network-only',
});
const updated = data?.notifications.job;
if (updated) {
setJob(key, updated);
}
}, 750);
};
const isJobActive = (key: keyof typeof jobPollers) => {
const job = activeJobs[key];
return Boolean(job && activeStates.includes(job.state));
};
const jobLabel = (key: keyof typeof jobPollers, fallback: string) => {
const job = activeJobs[key];
if (job && activeStates.includes(job.state)) {
return t('notifications.sidebar.processingStatus', {
processed: job.processed,
total: job.total,
});
}
return fallback;
};
const { t } = useI18n();
const filterOptions = computed<Array<{ label: string; value?: Importance }>>(() => [
@@ -63,7 +150,12 @@ const confirmAndArchiveAll = async () => {
confirmVariant: 'primary',
});
if (confirmed) {
await archiveAll();
const { data } = await startArchiveAllJob({ importance: importance.value });
const job = data?.notifications.startArchiveAll;
if (job) {
setJob('archiveAll', job);
pollJob('archiveAll');
}
}
};
@@ -75,7 +167,29 @@ const confirmAndDeleteArchives = async () => {
confirmVariant: 'destructive',
});
if (confirmed) {
await deleteArchives();
const { data } = await startDeleteAll({ type: NotificationType.ARCHIVE });
const job = data?.notifications.startDeleteAll;
if (job) {
setJob('deleteArchived', job);
pollJob('deleteArchived');
}
}
};
const confirmAndDeleteUnread = async () => {
const confirmed = await confirm({
title: t('notifications.sidebar.confirmDeleteUnread.title'),
description: t('notifications.sidebar.confirmDeleteUnread.description'),
confirmText: t('notifications.sidebar.confirmDeleteUnread.confirmText'),
confirmVariant: 'destructive',
});
if (confirmed) {
const { data } = await startDeleteAll({ type: NotificationType.UNREAD });
const job = data?.notifications.startDeleteAll;
if (job) {
setJob('deleteUnread', job);
pollJob('deleteUnread');
}
}
};
@@ -139,6 +253,10 @@ const readArchivedCount = computed(() => {
const prepareToViewNotifications = () => {
void recalculateOverview();
};
onBeforeUnmount(() => {
Object.values(jobPollers).forEach((interval) => interval && clearInterval(interval));
});
</script>
<template>
@@ -179,24 +297,33 @@ const prepareToViewNotifications = () => {
</TabsList>
<TabsContent value="unread" class="flex-col items-end">
<Button
:disabled="loadingArchiveAll"
:disabled="loadingArchiveAll || isJobActive('archiveAll')"
variant="link"
size="sm"
class="text-foreground hover:text-destructive transition-none"
@click="confirmAndArchiveAll"
>
{{ t('notifications.sidebar.archiveAllAction') }}
{{ jobLabel('archiveAll', t('notifications.sidebar.archiveAllAction')) }}
</Button>
<Button
:disabled="loadingDeleteAll || isJobActive('deleteUnread')"
variant="link"
size="sm"
class="text-foreground hover:text-destructive transition-none"
@click="confirmAndDeleteUnread"
>
{{ jobLabel('deleteUnread', t('notifications.sidebar.deleteAllAction')) }}
</Button>
</TabsContent>
<TabsContent value="archived" class="flex-col items-end">
<Button
:disabled="loadingDeleteAll"
:disabled="loadingDeleteAll || isJobActive('deleteArchived')"
variant="link"
size="sm"
class="text-foreground hover:text-destructive transition-none"
@click="confirmAndDeleteArchives"
>
{{ t('notifications.sidebar.deleteAllAction') }}
{{ jobLabel('deleteArchived', t('notifications.sidebar.deleteAllAction')) }}
</Button>
</TabsContent>
</div>

View File

@@ -23,6 +23,17 @@ export const NOTIFICATION_COUNT_FRAGMENT = graphql(/* GraphQL */ `
}
`);
export const NOTIFICATION_JOB_FRAGMENT = graphql(/* GraphQL */ `
fragment NotificationJobFragment on NotificationJob {
id
operation
state
processed
total
error
}
`);
export const getNotifications = graphql(/* GraphQL */ `
query Notifications($filter: NotificationFilter!) {
notifications {
@@ -42,40 +53,36 @@ export const archiveNotification = graphql(/* GraphQL */ `
}
`);
export const archiveAllNotifications = graphql(/* GraphQL */ `
mutation ArchiveAllNotifications {
archiveAll {
unread {
total
}
archive {
info
warning
alert
total
}
}
}
`);
export const deleteNotification = graphql(/* GraphQL */ `
mutation DeleteNotification($id: PrefixedID!, $type: NotificationType!) {
deleteNotification(id: $id, type: $type) {
archive {
total
notifications {
delete(id: $id, type: $type) {
archive {
total
}
unread {
total
}
}
}
}
`);
export const deleteArchivedNotifications = graphql(/* GraphQL */ `
mutation DeleteAllNotifications {
deleteArchivedNotifications {
archive {
total
export const archiveAllNotifications = graphql(/* GraphQL */ `
mutation StartArchiveAllNotifications($importance: NotificationImportance) {
notifications {
startArchiveAll(importance: $importance) {
...NotificationJobFragment
}
unread {
total
}
}
`);
export const startDeleteNotifications = graphql(/* GraphQL */ `
mutation StartDeleteNotifications($type: NotificationType) {
notifications {
startDeleteAll(type: $type) {
...NotificationJobFragment
}
}
}
@@ -100,6 +107,16 @@ export const notificationsOverview = graphql(/* GraphQL */ `
}
`);
export const notificationJobStatus = graphql(/* GraphQL */ `
query NotificationJobStatus($id: ID!) {
notifications {
job(id: $id) {
...NotificationJobFragment
}
}
}
`);
/** Re-calculates the notifications overview (i.e. notification counts) */
export const resetOverview = graphql(/* GraphQL */ `
mutation RecomputeOverview {

View File

@@ -304,7 +304,11 @@
"notifications.sidebar.confirmDeleteAll.confirmText": "Delete All",
"notifications.sidebar.confirmDeleteAll.description": "This will permanently delete all archived notifications currently on your Unraid server. This action cannot be undone.",
"notifications.sidebar.confirmDeleteAll.title": "Delete All Archived Notifications",
"notifications.sidebar.confirmDeleteUnread.confirmText": "Delete",
"notifications.sidebar.confirmDeleteUnread.description": "Are you sure you want to permanently delete all unread notifications? This cannot be undone.",
"notifications.sidebar.confirmDeleteUnread.title": "Delete unread notifications",
"notifications.sidebar.deleteAllAction": "Delete All",
"notifications.sidebar.processingStatus": "Processing {{processed}} / {{total}}",
"notifications.sidebar.editSettingsTooltip": "Edit Notification Settings",
"notifications.sidebar.filters.alert": "Alert",
"notifications.sidebar.filters.all": "All Types",