fix: load notifications from file system instead of redux state

- Adds a Nest.js service for notifications
- Helps improve our memory footprint!
This commit is contained in:
Pujit Mehrotra
2024-09-11 17:38:17 -04:00
parent d426001372
commit b178247046
12 changed files with 199 additions and 75 deletions

View File

@@ -13,6 +13,6 @@ PLAYGROUND=true
INTROSPECTION=true
MOTHERSHIP_GRAPHQL_LINK="http://authenticator:3000/graphql"
NODE_TLS_REJECT_UNAUTHORIZED=0
BYPASS_PERMISSION_CHECKS=false
BYPASS_CORS_CHECKS=false
BYPASS_PERMISSION_CHECKS=true
BYPASS_CORS_CHECKS=true
CHOKIDAR_USEPOLLING=true

View File

@@ -0,0 +1,8 @@
export interface NotificationIni {
timestamp: string;
event: string;
subject: string;
description: string;
importance: 'normal' | 'alert' | 'warning';
link?: string;
}

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, NotificationInput, 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, 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 { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
type Properties<T> = Required<{
@@ -610,19 +610,6 @@ export function NotificationFilterSchema(): z.ZodObject<Properties<NotificationF
})
}
export function NotificationInputSchema(): z.ZodObject<Properties<NotificationInput>> {
return z.object({
description: z.string().nullish(),
id: z.string(),
importance: ImportanceSchema,
link: z.string().nullish(),
subject: z.string(),
timestamp: z.string().nullish(),
title: z.string(),
type: NotificationTypeSchema
})
}
export function OsSchema(): z.ZodObject<Properties<Os>> {
return z.object({
__typename: z.literal('Os').optional(),

View File

@@ -781,20 +781,8 @@ export type NotificationFilter = {
type?: InputMaybe<NotificationType>;
};
export type NotificationInput = {
description?: InputMaybe<Scalars['String']['input']>;
id: Scalars['ID']['input'];
importance: Importance;
link?: InputMaybe<Scalars['String']['input']>;
subject: Scalars['String']['input'];
timestamp?: InputMaybe<Scalars['String']['input']>;
title: Scalars['String']['input'];
type: NotificationType;
};
export enum NotificationType {
ARCHIVED = 'ARCHIVED',
RESTORED = 'RESTORED',
ARCHIVE = 'ARCHIVE',
UNREAD = 'UNREAD'
}
@@ -1724,7 +1712,6 @@ export type ResolversTypes = ResolversObject<{
Node: ResolverTypeWrapper<ResolversInterfaceTypes<ResolversTypes>['Node']>;
Notification: ResolverTypeWrapper<Notification>;
NotificationFilter: NotificationFilter;
NotificationInput: NotificationInput;
NotificationType: NotificationType;
Os: ResolverTypeWrapper<Os>;
Owner: ResolverTypeWrapper<Owner>;
@@ -1829,7 +1816,6 @@ export type ResolversParentTypes = ResolversObject<{
Node: ResolversInterfaceTypes<ResolversParentTypes>['Node'];
Notification: Notification;
NotificationFilter: NotificationFilter;
NotificationInput: NotificationInput;
Os: Os;
Owner: Owner;
ParityCheck: ParityCheck;

View File

@@ -201,7 +201,9 @@ export function RemoteAccessInputSchema(): z.ZodObject<Properties<RemoteAccessIn
export function RemoteGraphQLClientInputSchema(): z.ZodObject<Properties<RemoteGraphQLClientInput>> {
return z.object({
apiKey: z.string(),
body: z.string()
body: z.string(),
timeout: z.number().nullish(),
ttl: z.number().nullish()
})
}

View File

@@ -1,18 +1,6 @@
enum NotificationType {
UNREAD
ARCHIVED
RESTORED
}
input NotificationInput {
id: ID!
title: String!
subject: String!
description: String
importance: Importance!
link: String
type: NotificationType!
timestamp: String
ARCHIVE
}
input NotificationFilter {
@@ -47,3 +35,11 @@ type Notification {
""" ISO Timestamp for when the notification occurred """
timestamp: String
}
type NotificationOverview {
unread: Int!
archived: Int!
critical: Int!
warning: Int!
alert: Int!
}

View File

@@ -14,6 +14,7 @@ import {
createSlice,
} from '@reduxjs/toolkit';
import { PUBSUB_CHANNEL, pubsub } from '@app/core/pubsub';
import { NotificationIni } from '@app/core/types/states/notification';
interface NotificationState {
notifications: Record<string, Notification>;
@@ -23,15 +24,6 @@ const notificationInitialState: NotificationState = {
notifications: {},
};
interface NotificationIni {
timestamp: string;
event: string;
subject: string;
description: string;
importance: 'normal' | 'alert' | 'warning';
link?: string;
}
const fileImportanceToGqlImportance = (
importance: NotificationIni['importance']
): Importance => {
@@ -64,7 +56,7 @@ export const loadNotification = createAsyncThunk<
type: 'ini',
});
const notification: NotificationInput = {
const notification: Notification = {
id: path,
title: notificationFile.event,
subject: notificationFile.subject,

View File

@@ -4,9 +4,12 @@ import { Query, Resolver, Args, Subscription } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import { UseRoles } from 'nest-access-control';
import { PUBSUB_CHANNEL, createSubscription } from '@app/core/pubsub';
import { NotificationsService } from './notifications.service';
@Resolver()
export class NotificationsResolver {
constructor(readonly notificationsService: NotificationsService) {}
@Query()
@UseRoles({
resource: 'notifications',
@@ -17,26 +20,7 @@ export class NotificationsResolver {
@Args('filter')
{ limit, importance, type, offset }: NotificationFilter
) {
if (limit > 50) {
throw new GraphQLError('Limit must be less than 50');
}
return Object.values(getters.notifications().notifications)
.filter((notification) => {
if (importance && importance !== notification.importance) {
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()
)
.slice(offset, limit + offset);
return await this.notificationsService.getNotifications();
}
@Subscription('notificationAdded')

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotificationsService } from './notifications.service';
describe('NotificationsService', () => {
let service: NotificationsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [NotificationsService],
}).compile();
service = module.get<NotificationsService>(NotificationsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,126 @@
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, } from '@app/graphql/generated/api/types';
import { getters } from '@app/store';
import { Injectable } from '@nestjs/common';
import { readdir, stat } from 'fs/promises';
import { join } from 'path';
import { Logger } from '@nestjs/common';
import { isFulfilled, isRejected } from '@app/utils';
@Injectable()
export class NotificationsService {
private logger = new Logger(NotificationsService.name);
/**
* Retrieves all notifications from the file system.
*
* @returns An array of all notifications in the system.
*/
public async getNotifications(): Promise<Notification[]> {
this.logger.debug('Getting Notifications');
const logErrors = (error: unknown) => this.logger.error(error);
const notificationBasePath = getters.dynamix().notify!.path;
const [unreadNotifications, unreadNotificationErrors] = await this.getUnreadNotifications(notificationBasePath);
this.logger.debug(`${unreadNotifications.length} Unread Notifications`);
this.logger.debug(`${unreadNotificationErrors.length} Unread Notification Errors`);
unreadNotificationErrors.forEach(logErrors);
const [archivedNotifications, archivedNotificationErrors] = await this.getArchiveNotifications(notificationBasePath);
this.logger.debug(`${archivedNotifications.length} Archived Notifications`);
this.logger.debug(`${archivedNotificationErrors.length} Archived Notification Errors`);
archivedNotificationErrors.forEach(logErrors);
return [...unreadNotifications, ...archivedNotifications];
}
private getUnreadNotifications(notificationsDirectory: string) {
const unreadsDirectoryPath = join(notificationsDirectory, NotificationType.UNREAD.toLowerCase());
return this.getNotificationsFromDirectory(unreadsDirectoryPath, NotificationType.UNREAD);
}
private getArchiveNotifications(notificationsDirectory: string) {
const archivedDirectoryPath = join(notificationsDirectory, NotificationType.ARCHIVE.toLowerCase());
return this.getNotificationsFromDirectory(archivedDirectoryPath, NotificationType.ARCHIVE);
}
/**
* Given a directory path, reads all the files in the directory,
* and attempts to parse each file as a Notification.
* Returns an array of two elements:
* - the first element is an array of successfully parsed Notifications,
* - the second element is an array of errors for any files that failed parsing.
* @param containingDirectory the directory to read
* @param type the type of notification to load
* @returns an array of two elements: [successes, errors/failures]
*/
private async getNotificationsFromDirectory(containingDirectory: string, type: NotificationType): Promise<[Notification[], unknown[]]> {
const loadNotification = (filePath: string) => this.loadNotificationFile(filePath, type);
const contents = await readdir(containingDirectory);
const absolutePaths = contents.map((content) => join(containingDirectory, content));
const fileReads = absolutePaths.map(loadNotification);
const results = await Promise.allSettled(fileReads);
return [
results.filter(isFulfilled).map((result) => result.value),
results.filter(isRejected).map((result) => result.reason),
];
}
/**
* Loads a notification file from disk, parses it to a Notification object, and
* validates the object against the NotificationSchema.
*
* @param path The path to the notification file on disk.
* @param type The type of the notification that is being loaded.
* @returns A parsed Notification object, or throws an error if the object is invalid.
* @throws An error if the object is invalid (doesn't conform to the graphql NotificationSchema).
*/
private async loadNotificationFile(path: string, type: NotificationType): Promise<Notification> {
const notificationFile = parseConfig<NotificationIni>({
filePath: path,
type: 'ini',
});
const notification: Notification = {
id: path,
title: notificationFile.event,
subject: notificationFile.subject,
description: notificationFile.description ?? '',
importance: this.fileImportanceToGqlImportance(notificationFile.importance),
link: notificationFile.link,
timestamp: this.parseNotificationDateToIsoDate(notificationFile.timestamp),
type
};
return NotificationSchema().parse(notification);
}
private fileImportanceToGqlImportance(
importance: NotificationIni['importance']
): Importance {
switch (importance) {
case 'alert':
return Importance.ALERT;
case 'warning':
return Importance.WARNING;
default:
return Importance.INFO;
}
};
private parseNotificationDateToIsoDate(
unixStringSeconds: string | undefined
): string | null {
if (unixStringSeconds && !isNaN(Number(unixStringSeconds))) {
return new Date(Number(unixStringSeconds) * 1_000).toISOString();
}
return null;
};
}

View File

@@ -14,6 +14,7 @@ import { RegistrationResolver } from './registration/registration.resolver';
import { ServerResolver } from './servers/server.resolver';
import { VarsResolver } from './vars/vars.resolver';
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver';
import { NotificationsService } from './notifications/notifications.service';
@Module({
providers: [
@@ -32,6 +33,7 @@ import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.re
ServerResolver,
VarsResolver,
VmsResolver,
NotificationsService,
],
})
export class ResolversModule {}

23
api/src/utils.ts Normal file
View File

@@ -0,0 +1,23 @@
export function notNull<T>(value: T): value is NonNullable<T> {
return value !== null;
}
/**
* Checks if a PromiseSettledResult is fulfilled.
*
* @param result A PromiseSettledResult.
* @returns true if the result is fulfilled, false otherwise.
*/
export function isFulfilled<T>(result: PromiseSettledResult<T>): result is PromiseFulfilledResult<T> {
return result.status === 'fulfilled';
}
/**
* Checks if a PromiseSettledResult is rejected.
*
* @param result A PromiseSettledResult.
* @returns true if the result is rejected, false otherwise.
*/
export function isRejected<T>(result: PromiseSettledResult<T>): result is PromiseRejectedResult {
return result.status === 'rejected';
}