mirror of
https://github.com/unraid/api.git
synced 2026-01-21 07:59:41 -06:00
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:
@@ -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
|
||||
|
||||
8
api/src/core/types/states/notification.ts
Normal file
8
api/src/core/types/states/notification.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface NotificationIni {
|
||||
timestamp: string;
|
||||
event: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
importance: 'normal' | 'alert' | 'warning';
|
||||
link?: string;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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
23
api/src/utils.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user