diff --git a/api/src/core/pubsub.ts b/api/src/core/pubsub.ts index a9a7899dd..a18e5f849 100644 --- a/api/src/core/pubsub.ts +++ b/api/src/core/pubsub.ts @@ -11,6 +11,8 @@ export enum PUBSUB_CHANNEL { DISPLAY = 'DISPLAY', INFO = 'INFO', NOTIFICATION = 'NOTIFICATION', + NOTIFICATION_ADDED = 'NOTIFICATION_ADDED', + NOTIFICATION_OVERVIEW = 'NOTIFICATION_OVERVIEW', OWNER = 'OWNER', SERVERS = 'SERVERS', VMS = 'VMS', diff --git a/api/src/graphql/generated/api/operations.ts b/api/src/graphql/generated/api/operations.ts index f5b7134de..41080eef3 100755 --- a/api/src/graphql/generated/api/operations.ts +++ b/api/src/graphql/generated/api/operations.ts @@ -2,7 +2,7 @@ import * as Types from '@app/graphql/generated/api/types'; import { z } from 'zod' -import { AccessUrl, AccessUrlInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationFilter, NotificationType, Os, Owner, ParityCheck, Partition, Pci, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addApiKeyInput, addUserInput, arrayDiskInput, authenticateInput, deleteUserInput, mdState, registrationType, updateApikeyInput, usersInput } from '@app/graphql/generated/api/types' +import { AccessUrl, AccessUrlInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationsdataArgs, Os, Owner, ParityCheck, Partition, Pci, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addApiKeyInput, addUserInput, arrayDiskInput, authenticateInput, deleteUserInput, mdState, registrationType, updateApikeyInput, usersInput } from '@app/graphql/generated/api/types' import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; type Properties = Required<{ @@ -601,6 +601,16 @@ export function NotificationSchema(): z.ZodObject> { }) } +export function NotificationCountsSchema(): z.ZodObject> { + return z.object({ + __typename: z.literal('NotificationCounts').optional(), + alert: z.number(), + info: z.number(), + total: z.number(), + warning: z.number() + }) +} + export function NotificationFilterSchema(): z.ZodObject> { return z.object({ importance: ImportanceSchema.nullish(), @@ -610,6 +620,29 @@ export function NotificationFilterSchema(): z.ZodObject> { + return z.object({ + __typename: z.literal('NotificationOverview').optional(), + archive: NotificationCountsSchema(), + unread: NotificationCountsSchema() + }) +} + +export function NotificationsSchema(): z.ZodObject> { + return z.object({ + __typename: z.literal('Notifications').optional(), + data: z.array(NotificationSchema()), + id: z.string(), + overview: NotificationOverviewSchema() + }) +} + +export function NotificationsdataArgsSchema(): z.ZodObject> { + return z.object({ + filter: NotificationFilterSchema() + }) +} + export function OsSchema(): z.ZodObject> { return z.object({ __typename: z.literal('Os').optional(), diff --git a/api/src/graphql/generated/api/types.ts b/api/src/graphql/generated/api/types.ts index 1ddae711b..d8f4aeced 100644 --- a/api/src/graphql/generated/api/types.ts +++ b/api/src/graphql/generated/api/types.ts @@ -761,7 +761,7 @@ export type Node = { id: Scalars['ID']['output']; }; -export type Notification = { +export type Notification = Node & { __typename?: 'Notification'; description: Scalars['String']['output']; id: Scalars['ID']['output']; @@ -774,6 +774,14 @@ export type Notification = { type: NotificationType; }; +export type NotificationCounts = { + __typename?: 'NotificationCounts'; + alert: Scalars['Int']['output']; + info: Scalars['Int']['output']; + total: Scalars['Int']['output']; + warning: Scalars['Int']['output']; +}; + export type NotificationFilter = { importance?: InputMaybe; limit: Scalars['Int']['input']; @@ -781,11 +789,29 @@ export type NotificationFilter = { type?: InputMaybe; }; +export type NotificationOverview = { + __typename?: 'NotificationOverview'; + archive: NotificationCounts; + unread: NotificationCounts; +}; + export enum NotificationType { ARCHIVE = 'ARCHIVE', UNREAD = 'UNREAD' } +export type Notifications = Node & { + __typename?: 'Notifications'; + data: Array; + id: Scalars['ID']['output']; + overview: NotificationOverview; +}; + + +export type NotificationsdataArgs = { + filter: NotificationFilter; +}; + export type Os = { __typename?: 'Os'; arch?: Maybe; @@ -928,7 +954,7 @@ export type Query = { /** Current user account */ me?: Maybe; network?: Maybe; - notifications: Array; + notifications: Notifications; online?: Maybe; owner?: Maybe; parityHistory?: Maybe>>; @@ -970,11 +996,6 @@ export type QuerydockerNetworksArgs = { }; -export type QuerynotificationsArgs = { - filter: NotificationFilter; -}; - - export type QueryuserArgs = { id: Scalars['ID']['input']; }; @@ -1124,6 +1145,7 @@ export type Subscription = { info: Info; me?: Maybe; notificationAdded: Notification; + notificationsOverview: NotificationOverview; online: Scalars['Boolean']['output']; owner: Owner; parityHistory: ParityCheck; @@ -1638,7 +1660,7 @@ export type DirectiveResolverFn> = ResolversObject<{ - Node: ( ArrayType ) | ( Config ) | ( Connect ) | ( Docker ) | ( Info ) | ( Network ) | ( Service ) | ( Vars ); + Node: ( ArrayType ) | ( Config ) | ( Connect ) | ( Docker ) | ( Info ) | ( Network ) | ( Notification ) | ( Notifications ) | ( Service ) | ( Vars ); UserAccount: ( Me ) | ( User ); }>; @@ -1711,8 +1733,11 @@ export type ResolversTypes = ResolversObject<{ Network: ResolverTypeWrapper; Node: ResolverTypeWrapper['Node']>; Notification: ResolverTypeWrapper; + NotificationCounts: ResolverTypeWrapper; NotificationFilter: NotificationFilter; + NotificationOverview: ResolverTypeWrapper; NotificationType: NotificationType; + Notifications: ResolverTypeWrapper; Os: ResolverTypeWrapper; Owner: ResolverTypeWrapper; ParityCheck: ResolverTypeWrapper; @@ -1815,7 +1840,10 @@ export type ResolversParentTypes = ResolversObject<{ Network: Network; Node: ResolversInterfaceTypes['Node']; Notification: Notification; + NotificationCounts: NotificationCounts; NotificationFilter: NotificationFilter; + NotificationOverview: NotificationOverview; + Notifications: Notifications; Os: Os; Owner: Owner; ParityCheck: ParityCheck; @@ -2295,7 +2323,7 @@ export type NetworkResolvers; export type NodeResolvers = ResolversObject<{ - __resolveType: TypeResolveFn<'Array' | 'Config' | 'Connect' | 'Docker' | 'Info' | 'Network' | 'Service' | 'Vars', ParentType, ContextType>; + __resolveType: TypeResolveFn<'Array' | 'Config' | 'Connect' | 'Docker' | 'Info' | 'Network' | 'Notification' | 'Notifications' | 'Service' | 'Vars', ParentType, ContextType>; id?: Resolver; }>; @@ -2311,6 +2339,27 @@ export type NotificationResolvers; }>; +export type NotificationCountsResolvers = ResolversObject<{ + alert?: Resolver; + info?: Resolver; + total?: Resolver; + warning?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}>; + +export type NotificationOverviewResolvers = ResolversObject<{ + archive?: Resolver; + unread?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}>; + +export type NotificationsResolvers = ResolversObject<{ + data?: Resolver, ParentType, ContextType, RequireFields>; + id?: Resolver; + overview?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}>; + export type OsResolvers = ResolversObject<{ arch?: Resolver, ParentType, ContextType>; build?: Resolver, ParentType, ContextType>; @@ -2448,7 +2497,7 @@ export type QueryResolvers, ParentType, ContextType>; me?: Resolver, ParentType, ContextType>; network?: Resolver, ParentType, ContextType>; - notifications?: Resolver, ParentType, ContextType, RequireFields>; + notifications?: Resolver; online?: Resolver, ParentType, ContextType>; owner?: Resolver, ParentType, ContextType>; parityHistory?: Resolver>>, ParentType, ContextType>; @@ -2543,6 +2592,7 @@ export type SubscriptionResolvers; me?: SubscriptionResolver, "me", ParentType, ContextType>; notificationAdded?: SubscriptionResolver; + notificationsOverview?: SubscriptionResolver; online?: SubscriptionResolver; owner?: SubscriptionResolver; parityHistory?: SubscriptionResolver; @@ -2898,6 +2948,9 @@ export type Resolvers = ResolversObject<{ Network?: NetworkResolvers; Node?: NodeResolvers; Notification?: NotificationResolvers; + NotificationCounts?: NotificationCountsResolvers; + NotificationOverview?: NotificationOverviewResolvers; + Notifications?: NotificationsResolvers; Os?: OsResolvers; Owner?: OwnerResolvers; ParityCheck?: ParityCheckResolvers; diff --git a/api/src/graphql/schema/types/notifications/notifications.graphql b/api/src/graphql/schema/types/notifications/notifications.graphql index 0d6bf7432..fd25ac671 100644 --- a/api/src/graphql/schema/types/notifications/notifications.graphql +++ b/api/src/graphql/schema/types/notifications/notifications.graphql @@ -11,11 +11,12 @@ input NotificationFilter { } type Query { - notifications(filter: NotificationFilter!): [Notification!]! + notifications: Notifications! } type Subscription { notificationAdded: Notification! + notificationsOverview: NotificationOverview! } enum Importance { @@ -24,7 +25,13 @@ enum Importance { WARNING } -type Notification { +type Notifications implements Node { + id: ID! + overview: NotificationOverview! + data(filter: NotificationFilter!): [Notification!]! +} + +type Notification implements Node { id: ID! title: String! subject: String! @@ -37,9 +44,13 @@ type Notification { } type NotificationOverview { - unread: Int! - archived: Int! - critical: Int! + unread: NotificationCounts! + archive: NotificationCounts! +} + +type NotificationCounts { + info: Int! warning: Int! alert: Int! + total: Int! } \ No newline at end of file diff --git a/api/src/unraid-api/graph/graph.module.ts b/api/src/unraid-api/graph/graph.module.ts index 6f87b2783..58f0a9599 100644 --- a/api/src/unraid-api/graph/graph.module.ts +++ b/api/src/unraid-api/graph/graph.module.ts @@ -21,6 +21,7 @@ import { ConnectResolver } from './connect/connect.resolver'; import { ConnectService } from './connect/connect.service'; import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin'; + @Module({ imports: [ ResolversModule, 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 9b65f1f56..7cf67b125 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -1,10 +1,11 @@ import {type NotificationFilter} from '@app/graphql/generated/api/types'; -import {Args, Query, Resolver, Subscription} from '@nestjs/graphql'; +import {Args, Query, ResolveField, Resolver, Subscription} from '@nestjs/graphql'; import {UseRoles} from 'nest-access-control'; import {createSubscription, PUBSUB_CHANNEL} from '@app/core/pubsub'; import {NotificationsService} from './notifications.service'; +import { getServerIdentifier } from '@app/core/utils/server-identifier'; -@Resolver() +@Resolver('Notifications') export class NotificationsResolver { constructor(readonly notificationsService: NotificationsService) {} @@ -14,20 +15,43 @@ export class NotificationsResolver { action: 'read', possession: 'any', }) - public async notifications( + public async notifications() { + return { + id: getServerIdentifier('notifications'), + } + } + + @ResolveField() + public async overview() { + return await this.notificationsService.getOverview(); + } + + + @ResolveField() + public async data( @Args('filter') filters: NotificationFilter ) { return await this.notificationsService.getNotifications(filters); } - @Subscription('notificationAdded') + @Subscription() @UseRoles({ resource: 'notifications', action: 'read', possession: 'any', }) async notificationAdded() { - return createSubscription(PUBSUB_CHANNEL.NOTIFICATION); + return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_ADDED); + } + + @Subscription() + @UseRoles({ + resource: 'notifications', + action: 'read', + possession: 'any', + }) + async notificationsOverview() { + return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW); } } 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 664c4fa9d..67f3076b5 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -1,17 +1,89 @@ import { NotificationIni } from '@app/core/types/states/notification'; import { parseConfig } from '@app/core/utils/misc/parse-config'; import { NotificationSchema } from '@app/graphql/generated/api/operations'; -import {Importance, NotificationType, type Notification, NotificationFilter,} from '@app/graphql/generated/api/types'; +import { Importance, NotificationType, type Notification, NotificationFilter, NotificationOverview, } from '@app/graphql/generated/api/types'; import { getters } from '@app/store'; import { Injectable } from '@nestjs/common'; import { readdir } from 'fs/promises'; import { join } from 'path'; import { Logger } from '@nestjs/common'; import { isFulfilled, isRejected } from '@app/utils'; +import { FSWatcher, watch } from 'chokidar'; +import { FileLoadStatus } from '@app/store/types'; +import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub'; @Injectable() export class NotificationsService { private logger = new Logger(NotificationsService.name); + private static watcher: FSWatcher | null = null; + + private static overview: NotificationOverview = { + unread: { + alert: 0, + info: 0, + warning: 0, + total: 0, + }, + archive: { + alert: 0, + info: 0, + warning: 0, + total: 0, + }, + }; + + constructor() { + NotificationsService.watcher = this.getNotificationsWatcher(); + } + + private getNotificationsWatcher() { + const { notify, status } = getters.dynamix(); + if (status === FileLoadStatus.LOADED && notify?.path) { + if (NotificationsService.watcher) { + return NotificationsService.watcher; + } + + NotificationsService.watcher = watch(notify.path, {}) + .on('add', (path) => { + void this.handleNotificationAdd(path).catch((e) => this.logger.error(e)); + }) + // Do we even want to listen to removals? + .on('unlink', (path) => { + void this.handleNotificationRemoval(path).catch((e) => this.logger.error(e)); + }); + + return NotificationsService.watcher; + } + return null; + } + + private async handleNotificationAdd(path: string) { + // The path looks like /{notification base path}/{type}/{notification id} + const type = path.includes('/unread/') ? NotificationType.UNREAD : NotificationType.ARCHIVE; + this.logger.debug(`Adding ${type} Notification: ${path}`); + const notification = await this.loadNotificationFile(path, NotificationType[type]); + + NotificationsService.overview[type.toLowerCase()][notification.importance.toLowerCase()] += 1; + NotificationsService.overview[type.toLowerCase()]['total'] += 1; + + pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_ADDED, { + notificationAdded: notification + }); + + pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW, { + notificationsOverview: NotificationsService.overview + }); + } + + private async handleNotificationRemoval(path: string) { + pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW, { + notificationsOverview: NotificationsService.overview + }); + } + + public async getOverview(): Promise { + return NotificationsService.overview; + } /** * Retrieves all notifications from the file system. @@ -54,23 +126,23 @@ export class NotificationsService { const results = await Promise.allSettled(fileReads); return [ - results.filter(isFulfilled).map(result => result.value).filter((notification) => { - if (importance && importance !== notification.importance) { - return false; - } + results.filter(isFulfilled).map(result => result.value).filter((notification) => { + if (importance && importance !== notification.importance) { + return false; + } - if (type && type !== notification.type) { - return false; - } + if (type && type !== notification.type) { + return false; + } - return true; - }) - .sort( - (a, b) => - new Date(b.timestamp ?? 0).getTime() - - new Date(a.timestamp ?? 0).getTime() - ), - results.filter(isRejected).map((result) => result.reason), + return true; + }) + .sort( + (a, b) => + new Date(b.timestamp ?? 0).getTime() - + new Date(a.timestamp ?? 0).getTime() + ), + results.filter(isRejected).map((result) => result.reason), ]; }