feat: wrap Notifications in a GraphQL Node & implement notification overviews

This commit is contained in:
Pujit Mehrotra
2024-09-12 17:18:31 -04:00
parent 00294699f0
commit 69a6163e29
7 changed files with 233 additions and 37 deletions

View File

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

View File

@@ -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<T> = Required<{
@@ -601,6 +601,16 @@ export function NotificationSchema(): z.ZodObject<Properties<Notification>> {
})
}
export function NotificationCountsSchema(): z.ZodObject<Properties<NotificationCounts>> {
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<Properties<NotificationFilter>> {
return z.object({
importance: ImportanceSchema.nullish(),
@@ -610,6 +620,29 @@ export function NotificationFilterSchema(): z.ZodObject<Properties<NotificationF
})
}
export function NotificationOverviewSchema(): z.ZodObject<Properties<NotificationOverview>> {
return z.object({
__typename: z.literal('NotificationOverview').optional(),
archive: NotificationCountsSchema(),
unread: NotificationCountsSchema()
})
}
export function NotificationsSchema(): z.ZodObject<Properties<Notifications>> {
return z.object({
__typename: z.literal('Notifications').optional(),
data: z.array(NotificationSchema()),
id: z.string(),
overview: NotificationOverviewSchema()
})
}
export function NotificationsdataArgsSchema(): z.ZodObject<Properties<NotificationsdataArgs>> {
return z.object({
filter: NotificationFilterSchema()
})
}
export function OsSchema(): z.ZodObject<Properties<Os>> {
return z.object({
__typename: z.literal('Os').optional(),

View File

@@ -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<Importance>;
limit: Scalars['Int']['input'];
@@ -781,11 +789,29 @@ export type NotificationFilter = {
type?: InputMaybe<NotificationType>;
};
export type NotificationOverview = {
__typename?: 'NotificationOverview';
archive: NotificationCounts;
unread: NotificationCounts;
};
export enum NotificationType {
ARCHIVE = 'ARCHIVE',
UNREAD = 'UNREAD'
}
export type Notifications = Node & {
__typename?: 'Notifications';
data: Array<Notification>;
id: Scalars['ID']['output'];
overview: NotificationOverview;
};
export type NotificationsdataArgs = {
filter: NotificationFilter;
};
export type Os = {
__typename?: 'Os';
arch?: Maybe<Scalars['String']['output']>;
@@ -928,7 +954,7 @@ export type Query = {
/** Current user account */
me?: Maybe<Me>;
network?: Maybe<Network>;
notifications: Array<Notification>;
notifications: Notifications;
online?: Maybe<Scalars['Boolean']['output']>;
owner?: Maybe<Owner>;
parityHistory?: Maybe<Array<Maybe<ParityCheck>>>;
@@ -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<Me>;
notificationAdded: Notification;
notificationsOverview: NotificationOverview;
online: Scalars['Boolean']['output'];
owner: Owner;
parityHistory: ParityCheck;
@@ -1638,7 +1660,7 @@ export type DirectiveResolverFn<TResult = {}, TParent = {}, TContext = {}, TArgs
/** Mapping of interface types */
export type ResolversInterfaceTypes<RefType extends Record<string, unknown>> = 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<Network>;
Node: ResolverTypeWrapper<ResolversInterfaceTypes<ResolversTypes>['Node']>;
Notification: ResolverTypeWrapper<Notification>;
NotificationCounts: ResolverTypeWrapper<NotificationCounts>;
NotificationFilter: NotificationFilter;
NotificationOverview: ResolverTypeWrapper<NotificationOverview>;
NotificationType: NotificationType;
Notifications: ResolverTypeWrapper<Notifications>;
Os: ResolverTypeWrapper<Os>;
Owner: ResolverTypeWrapper<Owner>;
ParityCheck: ResolverTypeWrapper<ParityCheck>;
@@ -1815,7 +1840,10 @@ export type ResolversParentTypes = ResolversObject<{
Network: Network;
Node: ResolversInterfaceTypes<ResolversParentTypes>['Node'];
Notification: Notification;
NotificationCounts: NotificationCounts;
NotificationFilter: NotificationFilter;
NotificationOverview: NotificationOverview;
Notifications: Notifications;
Os: Os;
Owner: Owner;
ParityCheck: ParityCheck;
@@ -2295,7 +2323,7 @@ export type NetworkResolvers<ContextType = Context, ParentType extends Resolvers
}>;
export type NodeResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Node'] = ResolversParentTypes['Node']> = 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<ResolversTypes['ID'], ParentType, ContextType>;
}>;
@@ -2311,6 +2339,27 @@ export type NotificationResolvers<ContextType = Context, ParentType extends Reso
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type NotificationCountsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['NotificationCounts'] = ResolversParentTypes['NotificationCounts']> = ResolversObject<{
alert?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
info?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
total?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
warning?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type NotificationOverviewResolvers<ContextType = Context, ParentType extends ResolversParentTypes['NotificationOverview'] = ResolversParentTypes['NotificationOverview']> = ResolversObject<{
archive?: Resolver<ResolversTypes['NotificationCounts'], ParentType, ContextType>;
unread?: Resolver<ResolversTypes['NotificationCounts'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type NotificationsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Notifications'] = ResolversParentTypes['Notifications']> = ResolversObject<{
data?: Resolver<Array<ResolversTypes['Notification']>, ParentType, ContextType, RequireFields<NotificationsdataArgs, 'filter'>>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
overview?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type OsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Os'] = ResolversParentTypes['Os']> = ResolversObject<{
arch?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
build?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@@ -2448,7 +2497,7 @@ export type QueryResolvers<ContextType = Context, ParentType extends ResolversPa
info?: Resolver<Maybe<ResolversTypes['Info']>, ParentType, ContextType>;
me?: Resolver<Maybe<ResolversTypes['Me']>, ParentType, ContextType>;
network?: Resolver<Maybe<ResolversTypes['Network']>, ParentType, ContextType>;
notifications?: Resolver<Array<ResolversTypes['Notification']>, ParentType, ContextType, RequireFields<QuerynotificationsArgs, 'filter'>>;
notifications?: Resolver<ResolversTypes['Notifications'], ParentType, ContextType>;
online?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
owner?: Resolver<Maybe<ResolversTypes['Owner']>, ParentType, ContextType>;
parityHistory?: Resolver<Maybe<Array<Maybe<ResolversTypes['ParityCheck']>>>, ParentType, ContextType>;
@@ -2543,6 +2592,7 @@ export type SubscriptionResolvers<ContextType = Context, ParentType extends Reso
info?: SubscriptionResolver<ResolversTypes['Info'], "info", ParentType, ContextType>;
me?: SubscriptionResolver<Maybe<ResolversTypes['Me']>, "me", ParentType, ContextType>;
notificationAdded?: SubscriptionResolver<ResolversTypes['Notification'], "notificationAdded", ParentType, ContextType>;
notificationsOverview?: SubscriptionResolver<ResolversTypes['NotificationOverview'], "notificationsOverview", ParentType, ContextType>;
online?: SubscriptionResolver<ResolversTypes['Boolean'], "online", ParentType, ContextType>;
owner?: SubscriptionResolver<ResolversTypes['Owner'], "owner", ParentType, ContextType>;
parityHistory?: SubscriptionResolver<ResolversTypes['ParityCheck'], "parityHistory", ParentType, ContextType>;
@@ -2898,6 +2948,9 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
Network?: NetworkResolvers<ContextType>;
Node?: NodeResolvers<ContextType>;
Notification?: NotificationResolvers<ContextType>;
NotificationCounts?: NotificationCountsResolvers<ContextType>;
NotificationOverview?: NotificationOverviewResolvers<ContextType>;
Notifications?: NotificationsResolvers<ContextType>;
Os?: OsResolvers<ContextType>;
Owner?: OwnerResolvers<ContextType>;
ParityCheck?: ParityCheckResolvers<ContextType>;

View File

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

View File

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

View File

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

View File

@@ -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<NotificationOverview> {
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),
];
}